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}