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