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 _, Animation, AnimationExt, AnyElement, DismissEvent, Entity, EventEmitter,
  6    FocusHandle, Focusable, Hsla, Modifiers, ModifiersChangedEvent, Render, SharedString,
  7    prelude::*, pulsating_between,
  8};
  9use std::time::Duration;
 10use ui::{
 11    AgentThreadStatus, Color, CommonAnimationExt, DecoratedIcon, DiffStat, Icon, IconDecoration,
 12    IconDecorationKind, IconName, IconSize, Label, LabelSize, prelude::*,
 13};
 14use workspace::{ModalView, Workspace};
 15use zed_actions::agents_sidebar::ToggleThreadSwitcher;
 16
 17const PANEL_WIDTH_REMS: f32 = 28.;
 18
 19pub(crate) struct ThreadSwitcherEntry {
 20    pub session_id: acp::SessionId,
 21    pub title: SharedString,
 22    pub icon: IconName,
 23    pub icon_from_external_svg: Option<SharedString>,
 24    pub status: AgentThreadStatus,
 25    pub metadata: ThreadMetadata,
 26    pub workspace: Entity<Workspace>,
 27    pub worktree_name: Option<SharedString>,
 28    pub diff_stats: DiffStats,
 29    pub is_title_generating: bool,
 30    pub notified: bool,
 31    pub timestamp: SharedString,
 32}
 33
 34pub(crate) enum ThreadSwitcherEvent {
 35    Preview {
 36        metadata: ThreadMetadata,
 37        workspace: Entity<Workspace>,
 38    },
 39    Confirmed {
 40        metadata: ThreadMetadata,
 41        workspace: Entity<Workspace>,
 42    },
 43    Dismissed,
 44}
 45
 46pub(crate) struct ThreadSwitcher {
 47    focus_handle: FocusHandle,
 48    entries: Vec<ThreadSwitcherEntry>,
 49    selected_index: usize,
 50    init_modifiers: Option<Modifiers>,
 51}
 52
 53impl ThreadSwitcher {
 54    pub fn new(
 55        entries: Vec<ThreadSwitcherEntry>,
 56        select_last: bool,
 57        window: &mut gpui::Window,
 58        cx: &mut Context<Self>,
 59    ) -> Self {
 60        let init_modifiers = window.modifiers().modified().then_some(window.modifiers());
 61        let selected_index = if entries.is_empty() {
 62            0
 63        } else if select_last {
 64            entries.len() - 1
 65        } else {
 66            1.min(entries.len().saturating_sub(1))
 67        };
 68
 69        if let Some(entry) = entries.get(selected_index) {
 70            cx.emit(ThreadSwitcherEvent::Preview {
 71                metadata: entry.metadata.clone(),
 72                workspace: entry.workspace.clone(),
 73            });
 74        }
 75
 76        let focus_handle = cx.focus_handle();
 77        cx.on_focus_out(&focus_handle, window, |_this, _event, _window, cx| {
 78            cx.emit(ThreadSwitcherEvent::Dismissed);
 79            cx.emit(DismissEvent);
 80        })
 81        .detach();
 82
 83        Self {
 84            focus_handle,
 85            entries,
 86            selected_index,
 87            init_modifiers,
 88        }
 89    }
 90
 91    pub fn selected_entry(&self) -> Option<&ThreadSwitcherEntry> {
 92        self.entries.get(self.selected_index)
 93    }
 94
 95    #[cfg(test)]
 96    pub fn entries(&self) -> &[ThreadSwitcherEntry] {
 97        &self.entries
 98    }
 99
100    #[cfg(test)]
101    pub fn selected_index(&self) -> usize {
102        self.selected_index
103    }
104
105    pub fn cycle_selection(&mut self, cx: &mut Context<Self>) {
106        if self.entries.is_empty() {
107            return;
108        }
109        self.selected_index = (self.selected_index + 1) % self.entries.len();
110        self.emit_preview(cx);
111    }
112
113    pub fn select_last(&mut self, cx: &mut Context<Self>) {
114        if self.entries.is_empty() {
115            return;
116        }
117        if self.selected_index == 0 {
118            self.selected_index = self.entries.len() - 1;
119        } else {
120            self.selected_index -= 1;
121        }
122        self.emit_preview(cx);
123    }
124
125    fn emit_preview(&mut self, cx: &mut Context<Self>) {
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        if let Some(entry) = self.entries.get(self.selected_index) {
136            cx.emit(ThreadSwitcherEvent::Confirmed {
137                metadata: entry.metadata.clone(),
138                workspace: entry.workspace.clone(),
139            });
140        }
141        cx.emit(DismissEvent);
142    }
143
144    fn cancel(&mut self, _: &menu::Cancel, _window: &mut gpui::Window, cx: &mut Context<Self>) {
145        cx.emit(ThreadSwitcherEvent::Dismissed);
146        cx.emit(DismissEvent);
147    }
148
149    fn toggle(
150        &mut self,
151        action: &ToggleThreadSwitcher,
152        _window: &mut gpui::Window,
153        cx: &mut Context<Self>,
154    ) {
155        if action.select_last {
156            self.select_last(cx);
157        } else {
158            self.cycle_selection(cx);
159        }
160    }
161
162    fn handle_modifiers_changed(
163        &mut self,
164        event: &ModifiersChangedEvent,
165        window: &mut gpui::Window,
166        cx: &mut Context<Self>,
167    ) {
168        let Some(init_modifiers) = self.init_modifiers else {
169            return;
170        };
171        if !event.modified() || !init_modifiers.is_subset_of(event) {
172            self.init_modifiers = None;
173            if self.entries.is_empty() {
174                cx.emit(DismissEvent);
175            } else {
176                window.dispatch_action(menu::Confirm.boxed_clone(), cx);
177            }
178        }
179    }
180}
181
182impl ModalView for ThreadSwitcher {}
183
184impl EventEmitter<DismissEvent> for ThreadSwitcher {}
185impl EventEmitter<ThreadSwitcherEvent> for ThreadSwitcher {}
186
187impl Focusable for ThreadSwitcher {
188    fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle {
189        self.focus_handle.clone()
190    }
191}
192
193impl Render for ThreadSwitcher {
194    fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
195        let selected_index = self.selected_index;
196        let color = cx.theme().colors();
197        let panel_bg = color
198            .title_bar_background
199            .blend(color.panel_background.opacity(0.2));
200
201        v_flex()
202            .key_context("ThreadSwitcher")
203            .track_focus(&self.focus_handle)
204            .w(gpui::rems(PANEL_WIDTH_REMS))
205            .elevation_3(cx)
206            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
207            .on_action(cx.listener(Self::confirm))
208            .on_action(cx.listener(Self::cancel))
209            .on_action(cx.listener(Self::toggle))
210            .children(self.entries.iter().enumerate().map(|(ix, entry)| {
211                let is_first = ix == 0;
212                let is_last = ix == self.entries.len() - 1;
213                let selected = ix == selected_index;
214                let base_bg = if selected {
215                    color.element_active
216                } else {
217                    panel_bg
218                };
219
220                let dot_separator = || {
221                    Label::new("\u{2022}")
222                        .size(LabelSize::Small)
223                        .color(Color::Muted)
224                        .alpha(0.5)
225                };
226
227                let icon_container = || h_flex().size_4().flex_none().justify_center();
228
229                let agent_icon = || {
230                    if let Some(ref svg) = entry.icon_from_external_svg {
231                        Icon::from_external_svg(svg.clone())
232                            .color(Color::Muted)
233                            .size(IconSize::Small)
234                    } else {
235                        Icon::new(entry.icon)
236                            .color(Color::Muted)
237                            .size(IconSize::Small)
238                    }
239                };
240
241                let decoration = |kind: IconDecorationKind, deco_color: Hsla| {
242                    IconDecoration::new(kind, base_bg, cx)
243                        .color(deco_color)
244                        .position(gpui::Point {
245                            x: px(-2.),
246                            y: px(-2.),
247                        })
248                };
249
250                let icon_element: AnyElement = if entry.status == AgentThreadStatus::Running {
251                    icon_container()
252                        .child(
253                            Icon::new(IconName::LoadCircle)
254                                .size(IconSize::Small)
255                                .color(Color::Muted)
256                                .with_rotate_animation(2),
257                        )
258                        .into_any_element()
259                } else if entry.status == AgentThreadStatus::Error {
260                    icon_container()
261                        .child(DecoratedIcon::new(
262                            agent_icon(),
263                            Some(decoration(IconDecorationKind::X, cx.theme().status().error)),
264                        ))
265                        .into_any_element()
266                } else if entry.status == AgentThreadStatus::WaitingForConfirmation {
267                    icon_container()
268                        .child(DecoratedIcon::new(
269                            agent_icon(),
270                            Some(decoration(
271                                IconDecorationKind::Triangle,
272                                cx.theme().status().warning,
273                            )),
274                        ))
275                        .into_any_element()
276                } else if entry.notified {
277                    icon_container()
278                        .child(DecoratedIcon::new(
279                            agent_icon(),
280                            Some(decoration(IconDecorationKind::Dot, color.text_accent)),
281                        ))
282                        .into_any_element()
283                } else {
284                    icon_container().child(agent_icon()).into_any_element()
285                };
286
287                let title_label: AnyElement = if entry.is_title_generating {
288                    Label::new(entry.title.clone())
289                        .color(Color::Muted)
290                        .with_animation(
291                            "generating-title",
292                            Animation::new(Duration::from_secs(2))
293                                .repeat()
294                                .with_easing(pulsating_between(0.4, 0.8)),
295                            |label, delta| label.alpha(delta),
296                        )
297                        .into_any_element()
298                } else {
299                    Label::new(entry.title.clone()).into_any_element()
300                };
301
302                let has_diff_stats =
303                    entry.diff_stats.lines_added > 0 || entry.diff_stats.lines_removed > 0;
304                let has_worktree = entry.worktree_name.is_some();
305                let has_timestamp = !entry.timestamp.is_empty();
306
307                v_flex()
308                    .id(ix)
309                    .w_full()
310                    .py_1()
311                    .px_1p5()
312                    .border_1()
313                    .border_color(gpui::transparent_black())
314                    .when(selected, |s| s.bg(color.element_active))
315                    .when(is_first, |s| s.rounded_t_lg())
316                    .when(is_last, |s| s.rounded_b_lg())
317                    .child(
318                        h_flex()
319                            .min_w_0()
320                            .w_full()
321                            .gap_1p5()
322                            .child(icon_element)
323                            .child(title_label),
324                    )
325                    .when(has_worktree || has_diff_stats || has_timestamp, |this| {
326                        this.child(
327                            h_flex()
328                                .min_w_0()
329                                .gap_1p5()
330                                .child(icon_container())
331                                .when_some(entry.worktree_name.clone(), |this, worktree| {
332                                    this.child(
333                                        h_flex()
334                                            .gap_1()
335                                            .child(
336                                                Icon::new(IconName::GitWorktree)
337                                                    .size(IconSize::XSmall)
338                                                    .color(Color::Muted),
339                                            )
340                                            .child(
341                                                Label::new(worktree)
342                                                    .size(LabelSize::Small)
343                                                    .color(Color::Muted),
344                                            ),
345                                    )
346                                })
347                                .when(has_worktree && (has_diff_stats || has_timestamp), |this| {
348                                    this.child(dot_separator())
349                                })
350                                .when(has_diff_stats, |this| {
351                                    this.child(DiffStat::new(
352                                        ix,
353                                        entry.diff_stats.lines_added as usize,
354                                        entry.diff_stats.lines_removed as usize,
355                                    ))
356                                })
357                                .when(has_diff_stats && has_timestamp, |this| {
358                                    this.child(dot_separator())
359                                })
360                                .when(has_timestamp, |this| {
361                                    this.child(
362                                        Label::new(entry.timestamp.clone())
363                                            .size(LabelSize::Small)
364                                            .color(Color::Muted),
365                                    )
366                                }),
367                        )
368                    })
369            }))
370    }
371}