1use crate::{
2 CommonAnimationExt, DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration,
3 IconDecorationKind, Tooltip, prelude::*,
4};
5
6use gpui::{
7 Animation, AnimationExt, AnyView, ClickEvent, Hsla, MouseButton, SharedString,
8 pulsating_between,
9};
10use std::time::Duration;
11
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13pub enum AgentThreadStatus {
14 #[default]
15 Completed,
16 Running,
17 WaitingForConfirmation,
18 Error,
19}
20
21#[derive(IntoElement, RegisterComponent)]
22pub struct ThreadItem {
23 id: ElementId,
24 icon: IconName,
25 icon_color: Option<Color>,
26 icon_visible: bool,
27 custom_icon_from_external_svg: Option<SharedString>,
28 title: SharedString,
29 title_label_color: Option<Color>,
30 title_generating: bool,
31 highlight_positions: Vec<usize>,
32 timestamp: SharedString,
33 notified: bool,
34 status: AgentThreadStatus,
35 selected: bool,
36 focused: bool,
37 hovered: bool,
38 added: Option<usize>,
39 removed: Option<usize>,
40 worktree: Option<SharedString>,
41 worktree_full_path: Option<SharedString>,
42 worktree_highlight_positions: Vec<usize>,
43 on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
44 on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
45 action_slot: Option<AnyElement>,
46 tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
47}
48
49impl ThreadItem {
50 pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
51 Self {
52 id: id.into(),
53 icon: IconName::ZedAgent,
54 icon_color: None,
55 icon_visible: true,
56 custom_icon_from_external_svg: None,
57 title: title.into(),
58 title_label_color: None,
59 title_generating: false,
60 highlight_positions: Vec::new(),
61 timestamp: "".into(),
62 notified: false,
63 status: AgentThreadStatus::default(),
64 selected: false,
65 focused: false,
66 hovered: false,
67 added: None,
68 removed: None,
69 worktree: None,
70 worktree_full_path: None,
71 worktree_highlight_positions: Vec::new(),
72 on_click: None,
73 on_hover: Box::new(|_, _, _| {}),
74 action_slot: None,
75 tooltip: None,
76 }
77 }
78
79 pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
80 self.timestamp = timestamp.into();
81 self
82 }
83
84 pub fn icon(mut self, icon: IconName) -> Self {
85 self.icon = icon;
86 self
87 }
88
89 pub fn icon_color(mut self, color: Color) -> Self {
90 self.icon_color = Some(color);
91 self
92 }
93
94 pub fn icon_visible(mut self, visible: bool) -> Self {
95 self.icon_visible = visible;
96 self
97 }
98
99 pub fn custom_icon_from_external_svg(mut self, svg: impl Into<SharedString>) -> Self {
100 self.custom_icon_from_external_svg = Some(svg.into());
101 self
102 }
103
104 pub fn notified(mut self, notified: bool) -> Self {
105 self.notified = notified;
106 self
107 }
108
109 pub fn status(mut self, status: AgentThreadStatus) -> Self {
110 self.status = status;
111 self
112 }
113
114 pub fn title_generating(mut self, generating: bool) -> Self {
115 self.title_generating = generating;
116 self
117 }
118
119 pub fn title_label_color(mut self, color: Color) -> Self {
120 self.title_label_color = Some(color);
121 self
122 }
123
124 pub fn highlight_positions(mut self, positions: Vec<usize>) -> Self {
125 self.highlight_positions = positions;
126 self
127 }
128
129 pub fn selected(mut self, selected: bool) -> Self {
130 self.selected = selected;
131 self
132 }
133
134 pub fn focused(mut self, focused: bool) -> Self {
135 self.focused = focused;
136 self
137 }
138
139 pub fn added(mut self, added: usize) -> Self {
140 self.added = Some(added);
141 self
142 }
143
144 pub fn removed(mut self, removed: usize) -> Self {
145 self.removed = Some(removed);
146 self
147 }
148
149 pub fn worktree(mut self, worktree: impl Into<SharedString>) -> Self {
150 self.worktree = Some(worktree.into());
151 self
152 }
153
154 pub fn worktree_full_path(mut self, worktree_full_path: impl Into<SharedString>) -> Self {
155 self.worktree_full_path = Some(worktree_full_path.into());
156 self
157 }
158
159 pub fn worktree_highlight_positions(mut self, positions: Vec<usize>) -> Self {
160 self.worktree_highlight_positions = positions;
161 self
162 }
163
164 pub fn hovered(mut self, hovered: bool) -> Self {
165 self.hovered = hovered;
166 self
167 }
168
169 pub fn on_click(
170 mut self,
171 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
172 ) -> Self {
173 self.on_click = Some(Box::new(handler));
174 self
175 }
176
177 pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
178 self.on_hover = Box::new(on_hover);
179 self
180 }
181
182 pub fn action_slot(mut self, element: impl IntoElement) -> Self {
183 self.action_slot = Some(element.into_any_element());
184 self
185 }
186
187 pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
188 self.tooltip = Some(Box::new(tooltip));
189 self
190 }
191}
192
193impl RenderOnce for ThreadItem {
194 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
195 let color = cx.theme().colors();
196 let base_bg = color
197 .title_bar_background
198 .blend(color.panel_background.opacity(0.2));
199
200 let base_bg = if self.selected {
201 color.element_active
202 } else {
203 base_bg
204 };
205
206 let hover_color = color
207 .element_active
208 .blend(color.element_background.opacity(0.2));
209
210 let gradient_overlay = GradientFade::new(base_bg, hover_color, hover_color)
211 .width(px(64.0))
212 .right(px(-10.0))
213 .gradient_stop(0.75)
214 .group_name("thread-item");
215
216 let dot_separator = || {
217 Label::new("•")
218 .size(LabelSize::Small)
219 .color(Color::Muted)
220 .alpha(0.5)
221 };
222
223 let icon_id = format!("icon-{}", self.id);
224 let icon_visible = self.icon_visible;
225 let icon_container = || {
226 h_flex()
227 .id(icon_id.clone())
228 .size_4()
229 .flex_none()
230 .justify_center()
231 .when(!icon_visible, |this| this.invisible())
232 };
233 let icon_color = self.icon_color.unwrap_or(Color::Muted);
234 let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg {
235 Icon::from_external_svg(custom_svg)
236 .color(icon_color)
237 .size(IconSize::Small)
238 } else {
239 Icon::new(self.icon).color(icon_color).size(IconSize::Small)
240 };
241
242 let decoration = |icon: IconDecorationKind, color: Hsla| {
243 IconDecoration::new(icon, base_bg, cx)
244 .color(color)
245 .position(gpui::Point {
246 x: px(-2.),
247 y: px(-2.),
248 })
249 };
250
251 let (decoration, icon_tooltip) = if self.status == AgentThreadStatus::Error {
252 (
253 Some(decoration(IconDecorationKind::X, cx.theme().status().error)),
254 Some("Thread has an Error"),
255 )
256 } else if self.status == AgentThreadStatus::WaitingForConfirmation {
257 (
258 Some(decoration(
259 IconDecorationKind::Triangle,
260 cx.theme().status().warning,
261 )),
262 Some("Thread is Waiting for Confirmation"),
263 )
264 } else if self.notified {
265 (
266 Some(decoration(IconDecorationKind::Dot, color.text_accent)),
267 Some("Thread's Generation is Complete"),
268 )
269 } else {
270 (None, None)
271 };
272
273 let icon = if self.status == AgentThreadStatus::Running {
274 icon_container()
275 .child(
276 Icon::new(IconName::LoadCircle)
277 .size(IconSize::Small)
278 .color(Color::Muted)
279 .with_rotate_animation(2),
280 )
281 .into_any_element()
282 } else if let Some(decoration) = decoration {
283 icon_container()
284 .child(DecoratedIcon::new(agent_icon, Some(decoration)))
285 .when_some(icon_tooltip, |icon, tooltip| {
286 icon.tooltip(Tooltip::text(tooltip))
287 })
288 .into_any_element()
289 } else {
290 icon_container().child(agent_icon).into_any_element()
291 };
292
293 let title = self.title;
294 let highlight_positions = self.highlight_positions;
295
296 let title_label = if self.title_generating {
297 Label::new(title)
298 .color(Color::Muted)
299 .with_animation(
300 "generating-title",
301 Animation::new(Duration::from_secs(2))
302 .repeat()
303 .with_easing(pulsating_between(0.4, 0.8)),
304 |label, delta| label.alpha(delta),
305 )
306 .into_any_element()
307 } else if highlight_positions.is_empty() {
308 Label::new(title)
309 .when_some(self.title_label_color, |label, color| label.color(color))
310 .into_any_element()
311 } else {
312 HighlightedLabel::new(title, highlight_positions)
313 .when_some(self.title_label_color, |label, color| label.color(color))
314 .into_any_element()
315 };
316
317 let has_diff_stats = self.added.is_some() || self.removed.is_some();
318 let diff_stat_id = self.id.clone();
319 let added_count = self.added.unwrap_or(0);
320 let removed_count = self.removed.unwrap_or(0);
321
322 let has_worktree = self.worktree.is_some();
323 let has_timestamp = !self.timestamp.is_empty();
324 let timestamp = self.timestamp;
325
326 v_flex()
327 .id(self.id.clone())
328 .cursor_pointer()
329 .group("thread-item")
330 .relative()
331 .overflow_hidden()
332 .w_full()
333 .py_1()
334 .px_1p5()
335 .when(self.selected, |s| s.bg(color.element_active))
336 .border_1()
337 .border_color(gpui::transparent_black())
338 .when(self.focused, |s| s.border_color(color.border_focused))
339 .hover(|s| s.bg(hover_color))
340 .on_hover(self.on_hover)
341 .child(
342 h_flex()
343 .min_w_0()
344 .w_full()
345 .gap_2()
346 .justify_between()
347 .child(
348 h_flex()
349 .id("content")
350 .min_w_0()
351 .flex_1()
352 .gap_1p5()
353 .child(icon)
354 .child(title_label)
355 .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
356 )
357 .child(gradient_overlay)
358 .when(self.hovered, |this| {
359 this.when_some(self.action_slot, |this, slot| {
360 let overlay = GradientFade::new(base_bg, hover_color, hover_color)
361 .width(px(64.0))
362 .right(px(6.))
363 .gradient_stop(0.75)
364 .group_name("thread-item");
365
366 this.child(
367 h_flex()
368 .relative()
369 .on_mouse_down(MouseButton::Left, |_, _, cx| {
370 cx.stop_propagation()
371 })
372 .child(overlay)
373 .child(slot),
374 )
375 })
376 }),
377 )
378 .when(has_worktree || has_diff_stats || has_timestamp, |this| {
379 let worktree_full_path = self.worktree_full_path.clone().unwrap_or_default();
380 let worktree_label = self.worktree.map(|worktree| {
381 let positions = self.worktree_highlight_positions;
382 if positions.is_empty() {
383 Label::new(worktree)
384 .size(LabelSize::Small)
385 .color(Color::Muted)
386 .into_any_element()
387 } else {
388 HighlightedLabel::new(worktree, positions)
389 .size(LabelSize::Small)
390 .color(Color::Muted)
391 .into_any_element()
392 }
393 });
394
395 this.child(
396 h_flex()
397 .min_w_0()
398 .gap_1p5()
399 .child(icon_container()) // Icon Spacing
400 .when_some(worktree_label, |this, label| {
401 this.child(
402 h_flex()
403 .id(format!("{}-worktree", self.id.clone()))
404 .gap_0p5()
405 .child(
406 Icon::new(IconName::GitWorktree)
407 .size(IconSize::XSmall)
408 .color(Color::Muted),
409 )
410 .child(label)
411 .tooltip(move |_, cx| {
412 Tooltip::with_meta(
413 "Thread Running in a Local Git Worktree",
414 None,
415 worktree_full_path.clone(),
416 cx,
417 )
418 }),
419 )
420 })
421 .when(has_worktree && (has_diff_stats || has_timestamp), |this| {
422 this.child(dot_separator())
423 })
424 .when(has_diff_stats, |this| {
425 this.child(
426 DiffStat::new(diff_stat_id, added_count, removed_count)
427 .tooltip("Unreviewed changes"),
428 )
429 })
430 .when(has_diff_stats && has_timestamp, |this| {
431 this.child(dot_separator())
432 })
433 .when(has_timestamp, |this| {
434 this.child(
435 Label::new(timestamp.clone())
436 .size(LabelSize::Small)
437 .color(Color::Muted),
438 )
439 }),
440 )
441 })
442 .when_some(self.on_click, |this, on_click| this.on_click(on_click))
443 }
444}
445
446impl Component for ThreadItem {
447 fn scope() -> ComponentScope {
448 ComponentScope::Agent
449 }
450
451 fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
452 let container = || {
453 v_flex()
454 .w_72()
455 .border_1()
456 .border_color(cx.theme().colors().border_variant)
457 .bg(cx.theme().colors().panel_background)
458 };
459
460 let thread_item_examples = vec![
461 single_example(
462 "Default (minutes)",
463 container()
464 .child(
465 ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
466 .icon(IconName::AiOpenAi)
467 .timestamp("15m"),
468 )
469 .into_any_element(),
470 ),
471 single_example(
472 "Timestamp Only (hours)",
473 container()
474 .child(
475 ThreadItem::new("ti-1b", "Thread with just a timestamp")
476 .icon(IconName::AiClaude)
477 .timestamp("3h"),
478 )
479 .into_any_element(),
480 ),
481 single_example(
482 "Notified (weeks)",
483 container()
484 .child(
485 ThreadItem::new("ti-2", "Refine thread view scrolling behavior")
486 .timestamp("1w")
487 .notified(true),
488 )
489 .into_any_element(),
490 ),
491 single_example(
492 "Waiting for Confirmation",
493 container()
494 .child(
495 ThreadItem::new("ti-2b", "Execute shell command in terminal")
496 .timestamp("2h")
497 .status(AgentThreadStatus::WaitingForConfirmation),
498 )
499 .into_any_element(),
500 ),
501 single_example(
502 "Error",
503 container()
504 .child(
505 ThreadItem::new("ti-2c", "Failed to connect to language server")
506 .timestamp("5h")
507 .status(AgentThreadStatus::Error),
508 )
509 .into_any_element(),
510 ),
511 single_example(
512 "Running Agent",
513 container()
514 .child(
515 ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
516 .icon(IconName::AiClaude)
517 .timestamp("23h")
518 .status(AgentThreadStatus::Running),
519 )
520 .into_any_element(),
521 ),
522 single_example(
523 "In Worktree",
524 container()
525 .child(
526 ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
527 .icon(IconName::AiClaude)
528 .timestamp("2w")
529 .worktree("link-agent-panel"),
530 )
531 .into_any_element(),
532 ),
533 single_example(
534 "With Changes (months)",
535 container()
536 .child(
537 ThreadItem::new("ti-5", "Managing user and project settings interactions")
538 .icon(IconName::AiClaude)
539 .timestamp("1mo")
540 .added(10)
541 .removed(3),
542 )
543 .into_any_element(),
544 ),
545 single_example(
546 "Worktree + Changes + Timestamp",
547 container()
548 .child(
549 ThreadItem::new("ti-5b", "Full metadata example")
550 .icon(IconName::AiClaude)
551 .worktree("my-project")
552 .added(42)
553 .removed(17)
554 .timestamp("3w"),
555 )
556 .into_any_element(),
557 ),
558 single_example(
559 "Selected Item",
560 container()
561 .child(
562 ThreadItem::new("ti-6", "Refine textarea interaction behavior")
563 .icon(IconName::AiGemini)
564 .timestamp("45m")
565 .selected(true),
566 )
567 .into_any_element(),
568 ),
569 single_example(
570 "Focused Item (Keyboard Selection)",
571 container()
572 .child(
573 ThreadItem::new("ti-7", "Implement keyboard navigation")
574 .icon(IconName::AiClaude)
575 .timestamp("12h")
576 .focused(true),
577 )
578 .into_any_element(),
579 ),
580 single_example(
581 "Selected + Focused",
582 container()
583 .child(
584 ThreadItem::new("ti-8", "Active and keyboard-focused thread")
585 .icon(IconName::AiGemini)
586 .timestamp("2mo")
587 .selected(true)
588 .focused(true),
589 )
590 .into_any_element(),
591 ),
592 single_example(
593 "Hovered with Action Slot",
594 container()
595 .child(
596 ThreadItem::new("ti-9", "Hover to see action button")
597 .icon(IconName::AiClaude)
598 .timestamp("6h")
599 .hovered(true)
600 .action_slot(
601 IconButton::new("delete", IconName::Trash)
602 .icon_size(IconSize::Small)
603 .icon_color(Color::Muted),
604 ),
605 )
606 .into_any_element(),
607 ),
608 single_example(
609 "Search Highlight",
610 container()
611 .child(
612 ThreadItem::new("ti-10", "Implement keyboard navigation")
613 .icon(IconName::AiClaude)
614 .timestamp("4w")
615 .highlight_positions(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
616 )
617 .into_any_element(),
618 ),
619 single_example(
620 "Worktree Search Highlight",
621 container()
622 .child(
623 ThreadItem::new("ti-11", "Search in worktree name")
624 .icon(IconName::AiClaude)
625 .timestamp("3mo")
626 .worktree("my-project-name")
627 .worktree_highlight_positions(vec![3, 4, 5, 6, 7, 8, 9, 10, 11]),
628 )
629 .into_any_element(),
630 ),
631 ];
632
633 Some(
634 example_group(thread_item_examples)
635 .vertical()
636 .into_any_element(),
637 )
638 }
639}