thread_switcher.rs

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