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}