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