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