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