thread_switcher.rs

  1use action_log::DiffStats;
  2use agent_client_protocol as acp;
  3use agent_ui::thread_metadata_store::ThreadMetadata;
  4use gpui::{
  5    Action as _, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Modifiers,
  6    ModifiersChangedEvent, Render, ScrollHandle, SharedString, prelude::*,
  7};
  8use ui::{AgentThreadStatus, ThreadItem, ThreadItemWorktreeInfo, WithScrollbar, prelude::*};
  9use workspace::{ModalView, Workspace};
 10use zed_actions::agents_sidebar::ToggleThreadSwitcher;
 11
 12pub(crate) struct ThreadSwitcherEntry {
 13    pub session_id: acp::SessionId,
 14    pub title: SharedString,
 15    pub icon: IconName,
 16    pub icon_from_external_svg: Option<SharedString>,
 17    pub status: AgentThreadStatus,
 18    pub metadata: ThreadMetadata,
 19    pub workspace: Entity<Workspace>,
 20    pub project_name: Option<SharedString>,
 21    pub worktrees: Vec<ThreadItemWorktreeInfo>,
 22    pub diff_stats: DiffStats,
 23    pub is_title_generating: bool,
 24    pub notified: bool,
 25    pub timestamp: SharedString,
 26}
 27
 28pub(crate) enum ThreadSwitcherEvent {
 29    Preview {
 30        metadata: ThreadMetadata,
 31        workspace: Entity<Workspace>,
 32    },
 33    Confirmed {
 34        metadata: ThreadMetadata,
 35        workspace: Entity<Workspace>,
 36    },
 37    Dismissed,
 38}
 39
 40pub(crate) struct ThreadSwitcher {
 41    focus_handle: FocusHandle,
 42    entries: Vec<ThreadSwitcherEntry>,
 43    selected_index: usize,
 44    init_modifiers: Option<Modifiers>,
 45    scroll_handle: ScrollHandle,
 46}
 47
 48impl ThreadSwitcher {
 49    pub fn new(
 50        entries: Vec<ThreadSwitcherEntry>,
 51        select_last: bool,
 52        window: &mut gpui::Window,
 53        cx: &mut Context<Self>,
 54    ) -> Self {
 55        let init_modifiers = window.modifiers().modified().then_some(window.modifiers());
 56        let selected_index = if entries.is_empty() {
 57            0
 58        } else if select_last {
 59            entries.len() - 1
 60        } else {
 61            1.min(entries.len().saturating_sub(1))
 62        };
 63
 64        if let Some(entry) = entries.get(selected_index) {
 65            cx.emit(ThreadSwitcherEvent::Preview {
 66                metadata: entry.metadata.clone(),
 67                workspace: entry.workspace.clone(),
 68            });
 69        }
 70
 71        let focus_handle = cx.focus_handle();
 72        cx.on_focus_out(&focus_handle, window, |_this, _event, _window, cx| {
 73            cx.emit(ThreadSwitcherEvent::Dismissed);
 74            cx.emit(DismissEvent);
 75        })
 76        .detach();
 77
 78        let scroll_handle = ScrollHandle::new();
 79        scroll_handle.scroll_to_item(selected_index);
 80
 81        Self {
 82            focus_handle,
 83            entries,
 84            selected_index,
 85            init_modifiers,
 86            scroll_handle,
 87        }
 88    }
 89
 90    pub fn selected_entry(&self) -> Option<&ThreadSwitcherEntry> {
 91        self.entries.get(self.selected_index)
 92    }
 93
 94    #[cfg(test)]
 95    pub fn entries(&self) -> &[ThreadSwitcherEntry] {
 96        &self.entries
 97    }
 98
 99    #[cfg(test)]
100    pub fn selected_index(&self) -> usize {
101        self.selected_index
102    }
103
104    pub fn cycle_selection(&mut self, cx: &mut Context<Self>) {
105        if self.entries.is_empty() {
106            return;
107        }
108        self.selected_index = (self.selected_index + 1) % self.entries.len();
109        self.emit_preview(cx);
110    }
111
112    pub fn select_last(&mut self, cx: &mut Context<Self>) {
113        if self.entries.is_empty() {
114            return;
115        }
116        if self.selected_index == 0 {
117            self.selected_index = self.entries.len() - 1;
118        } else {
119            self.selected_index -= 1;
120        }
121        self.emit_preview(cx);
122    }
123
124    fn emit_preview(&mut self, cx: &mut Context<Self>) {
125        self.scroll_handle.scroll_to_item(self.selected_index);
126        if let Some(entry) = self.entries.get(self.selected_index) {
127            cx.emit(ThreadSwitcherEvent::Preview {
128                metadata: entry.metadata.clone(),
129                workspace: entry.workspace.clone(),
130            });
131        }
132    }
133
134    fn confirm(&mut self, _: &menu::Confirm, _window: &mut gpui::Window, cx: &mut Context<Self>) {
135        self.confirm_selected(cx);
136    }
137
138    fn confirm_selected(&mut self, cx: &mut Context<Self>) {
139        if let Some(entry) = self.entries.get(self.selected_index) {
140            cx.emit(ThreadSwitcherEvent::Confirmed {
141                metadata: entry.metadata.clone(),
142                workspace: entry.workspace.clone(),
143            });
144        }
145        cx.emit(DismissEvent);
146    }
147
148    fn select_and_confirm(&mut self, index: usize, cx: &mut Context<Self>) {
149        if index < self.entries.len() {
150            self.selected_index = index;
151            self.confirm_selected(cx);
152        }
153    }
154
155    fn select_index(&mut self, index: usize, cx: &mut Context<Self>) {
156        if index >= self.entries.len() || index == self.selected_index {
157            return;
158        }
159        self.selected_index = index;
160        self.scroll_handle.scroll_to_item(index);
161        self.emit_preview(cx);
162        cx.notify();
163    }
164
165    fn cancel(&mut self, _: &menu::Cancel, _window: &mut gpui::Window, cx: &mut Context<Self>) {
166        cx.emit(ThreadSwitcherEvent::Dismissed);
167        cx.emit(DismissEvent);
168    }
169
170    fn toggle(
171        &mut self,
172        action: &ToggleThreadSwitcher,
173        _window: &mut gpui::Window,
174        cx: &mut Context<Self>,
175    ) {
176        if action.select_last {
177            self.select_last(cx);
178        } else {
179            self.cycle_selection(cx);
180        }
181    }
182
183    fn handle_modifiers_changed(
184        &mut self,
185        event: &ModifiersChangedEvent,
186        window: &mut gpui::Window,
187        cx: &mut Context<Self>,
188    ) {
189        let Some(init_modifiers) = self.init_modifiers else {
190            return;
191        };
192        if !event.modified() || !init_modifiers.is_subset_of(event) {
193            self.init_modifiers = None;
194            if self.entries.is_empty() {
195                cx.emit(DismissEvent);
196            } else {
197                window.dispatch_action(menu::Confirm.boxed_clone(), cx);
198            }
199        }
200    }
201}
202
203impl ModalView for ThreadSwitcher {}
204
205impl EventEmitter<DismissEvent> for ThreadSwitcher {}
206impl EventEmitter<ThreadSwitcherEvent> for ThreadSwitcher {}
207
208impl Focusable for ThreadSwitcher {
209    fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle {
210        self.focus_handle.clone()
211    }
212}
213
214impl Render for ThreadSwitcher {
215    fn render(&mut self, window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
216        let selected_index = self.selected_index;
217
218        v_flex()
219            .key_context("ThreadSwitcher")
220            .track_focus(&self.focus_handle)
221            .p_1p5()
222            .w(rems_from_px(440.))
223            .elevation_3(cx)
224            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
225            .on_action(cx.listener(Self::confirm))
226            .on_action(cx.listener(Self::cancel))
227            .on_action(cx.listener(Self::toggle))
228            .child(
229                v_flex()
230                    .id("thread-switcher-list")
231                    .gap_0p5()
232                    .max_h_128()
233                    .overflow_y_scroll()
234                    .track_scroll(&self.scroll_handle)
235                    .children(self.entries.iter().enumerate().map(|(ix, entry)| {
236                        let id =
237                            SharedString::from(format!("thread-switcher-{}", entry.session_id));
238
239                        ThreadItem::new(id, entry.title.clone())
240                            .rounded(true)
241                            .icon(entry.icon)
242                            .status(entry.status)
243                            .when_some(entry.icon_from_external_svg.clone(), |this, svg| {
244                                this.custom_icon_from_external_svg(svg)
245                            })
246                            .when_some(entry.project_name.clone(), |this, name| {
247                                this.project_name(name)
248                            })
249                            .worktrees(entry.worktrees.clone())
250                            .timestamp(entry.timestamp.clone())
251                            .title_generating(entry.is_title_generating)
252                            .notified(entry.notified)
253                            .when(entry.diff_stats.lines_added > 0, |this| {
254                                this.added(entry.diff_stats.lines_added as usize)
255                            })
256                            .when(entry.diff_stats.lines_removed > 0, |this| {
257                                this.removed(entry.diff_stats.lines_removed as usize)
258                            })
259                            .selected(ix == selected_index)
260                            .base_bg(cx.theme().colors().elevated_surface_background)
261                            .on_hover(cx.listener(move |this, hovered: &bool, _window, cx| {
262                                if *hovered {
263                                    this.select_index(ix, cx);
264                                }
265                            }))
266                            // TODO: This is not properly propagating to the tread item.
267                            .on_click(cx.listener(
268                                move |this, _event: &gpui::ClickEvent, _window, cx| {
269                                    this.select_and_confirm(ix, cx);
270                                },
271                            ))
272                            .into_any_element()
273                    })),
274            )
275            .vertical_scrollbar_for(&self.scroll_handle, window, cx)
276    }
277}