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