1use crate::{
2 DecoratedIcon, DiffStat, HighlightedLabel, IconDecoration, IconDecorationKind, SpinnerLabel,
3 prelude::*,
4};
5
6use gpui::{AnyView, ClickEvent, Hsla, SharedString, linear_color_stop, linear_gradient};
7
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9pub enum AgentThreadStatus {
10 #[default]
11 Completed,
12 Running,
13 WaitingForConfirmation,
14 Error,
15}
16
17#[derive(IntoElement, RegisterComponent)]
18pub struct ThreadItem {
19 id: ElementId,
20 icon: IconName,
21 custom_icon_from_external_svg: Option<SharedString>,
22 title: SharedString,
23 timestamp: SharedString,
24 notified: bool,
25 status: AgentThreadStatus,
26 selected: bool,
27 focused: bool,
28 hovered: bool,
29 added: Option<usize>,
30 removed: Option<usize>,
31 worktree: Option<SharedString>,
32 highlight_positions: Vec<usize>,
33 worktree_highlight_positions: Vec<usize>,
34 on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
35 on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
36 action_slot: Option<AnyElement>,
37 tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
38}
39
40impl ThreadItem {
41 pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
42 Self {
43 id: id.into(),
44 icon: IconName::ZedAgent,
45 custom_icon_from_external_svg: None,
46 title: title.into(),
47 timestamp: "".into(),
48 notified: false,
49 status: AgentThreadStatus::default(),
50 selected: false,
51 focused: false,
52 hovered: false,
53 added: None,
54 removed: None,
55 worktree: None,
56 highlight_positions: Vec::new(),
57 worktree_highlight_positions: Vec::new(),
58 on_click: None,
59 on_hover: Box::new(|_, _, _| {}),
60 action_slot: None,
61 tooltip: None,
62 }
63 }
64
65 pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
66 self.timestamp = timestamp.into();
67 self
68 }
69
70 pub fn icon(mut self, icon: IconName) -> Self {
71 self.icon = icon;
72 self
73 }
74
75 pub fn custom_icon_from_external_svg(mut self, svg: impl Into<SharedString>) -> Self {
76 self.custom_icon_from_external_svg = Some(svg.into());
77 self
78 }
79
80 pub fn notified(mut self, notified: bool) -> Self {
81 self.notified = notified;
82 self
83 }
84
85 pub fn status(mut self, status: AgentThreadStatus) -> Self {
86 self.status = status;
87 self
88 }
89
90 pub fn selected(mut self, selected: bool) -> Self {
91 self.selected = selected;
92 self
93 }
94
95 pub fn focused(mut self, focused: bool) -> Self {
96 self.focused = focused;
97 self
98 }
99
100 pub fn added(mut self, added: usize) -> Self {
101 self.added = Some(added);
102 self
103 }
104
105 pub fn removed(mut self, removed: usize) -> Self {
106 self.removed = Some(removed);
107 self
108 }
109
110 pub fn worktree(mut self, worktree: impl Into<SharedString>) -> Self {
111 self.worktree = Some(worktree.into());
112 self
113 }
114
115 pub fn highlight_positions(mut self, positions: Vec<usize>) -> Self {
116 self.highlight_positions = positions;
117 self
118 }
119
120 pub fn worktree_highlight_positions(mut self, positions: Vec<usize>) -> Self {
121 self.worktree_highlight_positions = positions;
122 self
123 }
124
125 pub fn hovered(mut self, hovered: bool) -> Self {
126 self.hovered = hovered;
127 self
128 }
129
130 pub fn on_click(
131 mut self,
132 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
133 ) -> Self {
134 self.on_click = Some(Box::new(handler));
135 self
136 }
137
138 pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
139 self.on_hover = Box::new(on_hover);
140 self
141 }
142
143 pub fn action_slot(mut self, element: impl IntoElement) -> Self {
144 self.action_slot = Some(element.into_any_element());
145 self
146 }
147
148 pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
149 self.tooltip = Some(Box::new(tooltip));
150 self
151 }
152}
153
154impl RenderOnce for ThreadItem {
155 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
156 let color = cx.theme().colors();
157 // let dot_separator = || {
158 // Label::new("•")
159 // .size(LabelSize::Small)
160 // .color(Color::Muted)
161 // .alpha(0.5)
162 // };
163
164 let icon_container = || h_flex().size_4().flex_none().justify_center();
165 let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg {
166 Icon::from_external_svg(custom_svg)
167 .color(Color::Muted)
168 .size(IconSize::Small)
169 } else {
170 Icon::new(self.icon)
171 .color(Color::Muted)
172 .size(IconSize::Small)
173 };
174
175 let decoration = |icon: IconDecorationKind, color: Hsla| {
176 IconDecoration::new(icon, cx.theme().colors().surface_background, cx)
177 .color(color)
178 .position(gpui::Point {
179 x: px(-2.),
180 y: px(-2.),
181 })
182 };
183
184 let decoration = if self.status == AgentThreadStatus::WaitingForConfirmation {
185 Some(decoration(
186 IconDecorationKind::Triangle,
187 cx.theme().status().warning,
188 ))
189 } else if self.status == AgentThreadStatus::Error {
190 Some(decoration(IconDecorationKind::X, cx.theme().status().error))
191 } else if self.notified {
192 Some(decoration(IconDecorationKind::Dot, color.text_accent))
193 } else {
194 None
195 };
196
197 let icon = if let Some(decoration) = decoration {
198 icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration)))
199 } else {
200 icon_container().child(agent_icon)
201 };
202
203 let is_running = matches!(
204 self.status,
205 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
206 );
207 let running_or_action = is_running || (self.hovered && self.action_slot.is_some());
208
209 let title = self.title;
210 let highlight_positions = self.highlight_positions;
211 let title_label = if highlight_positions.is_empty() {
212 Label::new(title).into_any_element()
213 } else {
214 HighlightedLabel::new(title, highlight_positions).into_any_element()
215 };
216
217 let base_bg = if self.selected {
218 color.element_active
219 } else {
220 color.panel_background
221 };
222
223 let gradient_overlay = div()
224 .absolute()
225 .top_0()
226 .right(px(-10.0))
227 .w_12()
228 .h_full()
229 .bg(linear_gradient(
230 90.,
231 linear_color_stop(base_bg, 0.6),
232 linear_color_stop(base_bg.opacity(0.0), 0.),
233 ))
234 .group_hover("thread-item", |s| {
235 s.bg(linear_gradient(
236 90.,
237 linear_color_stop(color.element_hover, 0.6),
238 linear_color_stop(color.element_hover.opacity(0.0), 0.),
239 ))
240 });
241
242 v_flex()
243 .id(self.id.clone())
244 .group("thread-item")
245 .relative()
246 .overflow_hidden()
247 .cursor_pointer()
248 .w_full()
249 .map(|this| {
250 if self.worktree.is_some() {
251 this.p_2()
252 } else {
253 this.px_2().py_1()
254 }
255 })
256 .when(self.selected, |s| s.bg(color.element_active))
257 .border_1()
258 .border_color(gpui::transparent_black())
259 .when(self.focused, |s| s.border_color(color.panel_focused_border))
260 .hover(|s| s.bg(color.element_hover))
261 .on_hover(self.on_hover)
262 .child(
263 h_flex()
264 .min_w_0()
265 .w_full()
266 .gap_2()
267 .justify_between()
268 .child(
269 h_flex()
270 .id("content")
271 .min_w_0()
272 .flex_1()
273 .gap_1p5()
274 .child(icon)
275 .child(title_label)
276 .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
277 )
278 .child(gradient_overlay)
279 .when(running_or_action, |this| {
280 this.child(
281 h_flex()
282 .gap_1()
283 .when(is_running, |this| {
284 this.child(
285 icon_container()
286 .child(SpinnerLabel::new().color(Color::Accent)),
287 )
288 })
289 .when(self.hovered, |this| {
290 this.when_some(self.action_slot, |this, slot| this.child(slot))
291 }),
292 )
293 }),
294 )
295 .when_some(self.worktree, |this, worktree| {
296 let worktree_highlight_positions = self.worktree_highlight_positions;
297 let worktree_label = if worktree_highlight_positions.is_empty() {
298 Label::new(worktree)
299 .size(LabelSize::Small)
300 .color(Color::Muted)
301 .into_any_element()
302 } else {
303 HighlightedLabel::new(worktree, worktree_highlight_positions)
304 .size(LabelSize::Small)
305 .color(Color::Muted)
306 .into_any_element()
307 };
308
309 this.child(
310 h_flex()
311 .min_w_0()
312 .gap_1p5()
313 .child(icon_container()) // Icon Spacing
314 .child(worktree_label)
315 // TODO: Uncomment the elements below when we're ready to expose this data
316 // .child(dot_separator())
317 // .child(
318 // Label::new(self.timestamp)
319 // .size(LabelSize::Small)
320 // .color(Color::Muted),
321 // )
322 // .child(
323 // Label::new("•")
324 // .size(LabelSize::Small)
325 // .color(Color::Muted)
326 // .alpha(0.5),
327 // )
328 // .when(has_no_changes, |this| {
329 // this.child(
330 // Label::new("No Changes")
331 // .size(LabelSize::Small)
332 // .color(Color::Muted),
333 // )
334 // })
335 .when(self.added.is_some() || self.removed.is_some(), |this| {
336 this.child(DiffStat::new(
337 self.id,
338 self.added.unwrap_or(0),
339 self.removed.unwrap_or(0),
340 ))
341 }),
342 )
343 })
344 .when_some(self.on_click, |this, on_click| this.on_click(on_click))
345 }
346}
347
348impl Component for ThreadItem {
349 fn scope() -> ComponentScope {
350 ComponentScope::Agent
351 }
352
353 fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
354 let container = || {
355 v_flex()
356 .w_72()
357 .border_1()
358 .border_color(cx.theme().colors().border_variant)
359 .bg(cx.theme().colors().panel_background)
360 };
361
362 let thread_item_examples = vec![
363 single_example(
364 "Default",
365 container()
366 .child(
367 ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
368 .icon(IconName::AiOpenAi)
369 .timestamp("1:33 AM"),
370 )
371 .into_any_element(),
372 ),
373 single_example(
374 "Notified",
375 container()
376 .child(
377 ThreadItem::new("ti-2", "Refine thread view scrolling behavior")
378 .timestamp("12:12 AM")
379 .notified(true),
380 )
381 .into_any_element(),
382 ),
383 single_example(
384 "Waiting for Confirmation",
385 container()
386 .child(
387 ThreadItem::new("ti-2b", "Execute shell command in terminal")
388 .timestamp("12:15 AM")
389 .status(AgentThreadStatus::WaitingForConfirmation),
390 )
391 .into_any_element(),
392 ),
393 single_example(
394 "Error",
395 container()
396 .child(
397 ThreadItem::new("ti-2c", "Failed to connect to language server")
398 .timestamp("12:20 AM")
399 .status(AgentThreadStatus::Error),
400 )
401 .into_any_element(),
402 ),
403 single_example(
404 "Running Agent",
405 container()
406 .child(
407 ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
408 .icon(IconName::AiClaude)
409 .timestamp("7:30 PM")
410 .status(AgentThreadStatus::Running),
411 )
412 .into_any_element(),
413 ),
414 single_example(
415 "In Worktree",
416 container()
417 .child(
418 ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
419 .icon(IconName::AiClaude)
420 .timestamp("7:37 PM")
421 .worktree("link-agent-panel"),
422 )
423 .into_any_element(),
424 ),
425 single_example(
426 "With Changes",
427 container()
428 .child(
429 ThreadItem::new("ti-5", "Managing user and project settings interactions")
430 .icon(IconName::AiClaude)
431 .timestamp("7:37 PM")
432 .added(10)
433 .removed(3),
434 )
435 .into_any_element(),
436 ),
437 single_example(
438 "Selected Item",
439 container()
440 .child(
441 ThreadItem::new("ti-6", "Refine textarea interaction behavior")
442 .icon(IconName::AiGemini)
443 .timestamp("3:00 PM")
444 .selected(true),
445 )
446 .into_any_element(),
447 ),
448 single_example(
449 "Focused Item (Keyboard Selection)",
450 container()
451 .child(
452 ThreadItem::new("ti-7", "Implement keyboard navigation")
453 .icon(IconName::AiClaude)
454 .timestamp("4:00 PM")
455 .focused(true),
456 )
457 .into_any_element(),
458 ),
459 single_example(
460 "Selected + Focused",
461 container()
462 .child(
463 ThreadItem::new("ti-8", "Active and keyboard-focused thread")
464 .icon(IconName::AiGemini)
465 .timestamp("5:00 PM")
466 .selected(true)
467 .focused(true),
468 )
469 .into_any_element(),
470 ),
471 ];
472
473 Some(
474 example_group(thread_item_examples)
475 .vertical()
476 .into_any_element(),
477 )
478 }
479}