1use crate::{CommonAnimationExt, DiffStat, GradientFade, HighlightedLabel, Tooltip, prelude::*};
2
3use gpui::{
4 Animation, AnimationExt, AnyView, ClickEvent, Hsla, MouseButton, SharedString,
5 pulsating_between,
6};
7use itertools::Itertools as _;
8use std::{path::PathBuf, sync::Arc, time::Duration};
9
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
11pub enum AgentThreadStatus {
12 #[default]
13 Completed,
14 Running,
15 WaitingForConfirmation,
16 Error,
17}
18
19#[derive(Clone)]
20pub struct ThreadItemWorktreeInfo {
21 pub name: SharedString,
22 pub full_path: SharedString,
23 pub highlight_positions: Vec<usize>,
24}
25
26#[derive(IntoElement, RegisterComponent)]
27pub struct ThreadItem {
28 id: ElementId,
29 icon: IconName,
30 icon_color: Option<Color>,
31 icon_visible: bool,
32 custom_icon_from_external_svg: Option<SharedString>,
33 title: SharedString,
34 title_label_color: Option<Color>,
35 title_generating: bool,
36 highlight_positions: Vec<usize>,
37 timestamp: SharedString,
38 notified: bool,
39 status: AgentThreadStatus,
40 selected: bool,
41 focused: bool,
42 hovered: bool,
43 rounded: bool,
44 added: Option<usize>,
45 removed: Option<usize>,
46 project_paths: Option<Arc<[PathBuf]>>,
47 project_name: Option<SharedString>,
48 worktrees: Vec<ThreadItemWorktreeInfo>,
49 pending_worktree_restore: bool,
50 on_cancel_restore: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
51 on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
52 on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
53 action_slot: Option<AnyElement>,
54 tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
55 base_bg: Option<Hsla>,
56}
57
58impl ThreadItem {
59 pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
60 Self {
61 id: id.into(),
62 icon: IconName::ZedAgent,
63 icon_color: None,
64 icon_visible: true,
65 custom_icon_from_external_svg: None,
66 title: title.into(),
67 title_label_color: None,
68 title_generating: false,
69 highlight_positions: Vec::new(),
70 timestamp: "".into(),
71 notified: false,
72 status: AgentThreadStatus::default(),
73 selected: false,
74 focused: false,
75 hovered: false,
76 rounded: false,
77 added: None,
78 removed: None,
79
80 project_paths: None,
81 project_name: None,
82 worktrees: Vec::new(),
83 pending_worktree_restore: false,
84 on_cancel_restore: None,
85 on_click: None,
86 on_hover: Box::new(|_, _, _| {}),
87 action_slot: None,
88 tooltip: None,
89 base_bg: None,
90 }
91 }
92
93 pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
94 self.timestamp = timestamp.into();
95 self
96 }
97
98 pub fn icon(mut self, icon: IconName) -> Self {
99 self.icon = icon;
100 self
101 }
102
103 pub fn icon_color(mut self, color: Color) -> Self {
104 self.icon_color = Some(color);
105 self
106 }
107
108 pub fn icon_visible(mut self, visible: bool) -> Self {
109 self.icon_visible = visible;
110 self
111 }
112
113 pub fn custom_icon_from_external_svg(mut self, svg: impl Into<SharedString>) -> Self {
114 self.custom_icon_from_external_svg = Some(svg.into());
115 self
116 }
117
118 pub fn notified(mut self, notified: bool) -> Self {
119 self.notified = notified;
120 self
121 }
122
123 pub fn status(mut self, status: AgentThreadStatus) -> Self {
124 self.status = status;
125 self
126 }
127
128 pub fn title_generating(mut self, generating: bool) -> Self {
129 self.title_generating = generating;
130 self
131 }
132
133 pub fn title_label_color(mut self, color: Color) -> Self {
134 self.title_label_color = Some(color);
135 self
136 }
137
138 pub fn highlight_positions(mut self, positions: Vec<usize>) -> Self {
139 self.highlight_positions = positions;
140 self
141 }
142
143 pub fn selected(mut self, selected: bool) -> Self {
144 self.selected = selected;
145 self
146 }
147
148 pub fn focused(mut self, focused: bool) -> Self {
149 self.focused = focused;
150 self
151 }
152
153 pub fn added(mut self, added: usize) -> Self {
154 self.added = Some(added);
155 self
156 }
157
158 pub fn removed(mut self, removed: usize) -> Self {
159 self.removed = Some(removed);
160 self
161 }
162
163 pub fn project_paths(mut self, paths: Arc<[PathBuf]>) -> Self {
164 self.project_paths = Some(paths);
165 self
166 }
167
168 pub fn project_name(mut self, name: impl Into<SharedString>) -> Self {
169 self.project_name = Some(name.into());
170 self
171 }
172
173 pub fn worktrees(mut self, worktrees: Vec<ThreadItemWorktreeInfo>) -> Self {
174 self.worktrees = worktrees;
175 self
176 }
177
178 pub fn pending_worktree_restore(mut self, pending: bool) -> Self {
179 self.pending_worktree_restore = pending;
180 self
181 }
182
183 pub fn on_cancel_restore(
184 mut self,
185 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
186 ) -> Self {
187 self.on_cancel_restore = Some(Box::new(handler));
188 self
189 }
190
191 pub fn hovered(mut self, hovered: bool) -> Self {
192 self.hovered = hovered;
193 self
194 }
195
196 pub fn rounded(mut self, rounded: bool) -> Self {
197 self.rounded = rounded;
198 self
199 }
200
201 pub fn on_click(
202 mut self,
203 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
204 ) -> Self {
205 self.on_click = Some(Box::new(handler));
206 self
207 }
208
209 pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
210 self.on_hover = Box::new(on_hover);
211 self
212 }
213
214 pub fn action_slot(mut self, element: impl IntoElement) -> Self {
215 self.action_slot = Some(element.into_any_element());
216 self
217 }
218
219 pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
220 self.tooltip = Some(Box::new(tooltip));
221 self
222 }
223
224 pub fn base_bg(mut self, color: Hsla) -> Self {
225 self.base_bg = Some(color);
226 self
227 }
228}
229
230impl RenderOnce for ThreadItem {
231 fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
232 let pending_worktree_restore = self.pending_worktree_restore;
233 let action_slot = self.action_slot.take();
234 let color = cx.theme().colors();
235 let sidebar_base_bg = color
236 .title_bar_background
237 .blend(color.panel_background.opacity(0.25));
238
239 let raw_bg = self.base_bg.unwrap_or(sidebar_base_bg);
240 let apparent_bg = color.background.blend(raw_bg);
241
242 let base_bg = if self.selected {
243 apparent_bg.blend(color.element_active)
244 } else {
245 apparent_bg
246 };
247
248 let hover_color = color
249 .element_active
250 .blend(color.element_background.opacity(0.2));
251 let hover_bg = apparent_bg.blend(hover_color);
252
253 let gradient_overlay = GradientFade::new(base_bg, hover_bg, hover_bg)
254 .width(px(64.0))
255 .right(px(-10.0))
256 .gradient_stop(0.75)
257 .group_name("thread-item");
258
259 let dot_separator = || {
260 Label::new("•")
261 .size(LabelSize::Small)
262 .color(Color::Muted)
263 .alpha(0.5)
264 };
265
266 let icon_id = format!("icon-{}", self.id);
267 let icon_visible = self.icon_visible;
268 let icon_container = || {
269 h_flex()
270 .id(icon_id.clone())
271 .size_4()
272 .flex_none()
273 .justify_center()
274 .when(!icon_visible, |this| this.invisible())
275 };
276 let icon_color = self.icon_color.unwrap_or(Color::Muted);
277 let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg {
278 Icon::from_external_svg(custom_svg)
279 .color(icon_color)
280 .size(IconSize::Small)
281 } else {
282 Icon::new(self.icon).color(icon_color).size(IconSize::Small)
283 };
284
285 let (status_icon, icon_tooltip) = if self.status == AgentThreadStatus::Error {
286 (
287 Some(
288 Icon::new(IconName::Close)
289 .size(IconSize::Small)
290 .color(Color::Error),
291 ),
292 Some("Thread has an Error"),
293 )
294 } else if self.status == AgentThreadStatus::WaitingForConfirmation {
295 (
296 Some(
297 Icon::new(IconName::Warning)
298 .size(IconSize::XSmall)
299 .color(Color::Warning),
300 ),
301 Some("Thread is Waiting for Confirmation"),
302 )
303 } else if self.notified {
304 (
305 Some(
306 Icon::new(IconName::Circle)
307 .size(IconSize::Small)
308 .color(Color::Accent),
309 ),
310 Some("Thread's Generation is Complete"),
311 )
312 } else {
313 (None, None)
314 };
315
316 let icon = if self.pending_worktree_restore {
317 icon_container()
318 .child(
319 Icon::new(IconName::LoadCircle)
320 .size(IconSize::Small)
321 .color(Color::Muted)
322 .with_rotate_animation(2),
323 )
324 .into_any_element()
325 } else if self.status == AgentThreadStatus::Running {
326 icon_container()
327 .child(
328 Icon::new(IconName::LoadCircle)
329 .size(IconSize::Small)
330 .color(Color::Muted)
331 .with_rotate_animation(2),
332 )
333 .into_any_element()
334 } else if let Some(status_icon) = status_icon {
335 icon_container()
336 .child(status_icon)
337 .when_some(icon_tooltip, |icon, tooltip| {
338 icon.tooltip(Tooltip::text(tooltip))
339 })
340 .into_any_element()
341 } else {
342 icon_container().child(agent_icon).into_any_element()
343 };
344
345 let title = self.title;
346 let highlight_positions = self.highlight_positions;
347
348 let title_label = if self.title_generating {
349 Label::new(title)
350 .color(Color::Muted)
351 .with_animation(
352 "generating-title",
353 Animation::new(Duration::from_secs(2))
354 .repeat()
355 .with_easing(pulsating_between(0.4, 0.8)),
356 |label, delta| label.alpha(delta),
357 )
358 .into_any_element()
359 } else if highlight_positions.is_empty() {
360 Label::new(title)
361 .when_some(self.title_label_color, |label, color| label.color(color))
362 .into_any_element()
363 } else {
364 HighlightedLabel::new(title, highlight_positions)
365 .when_some(self.title_label_color, |label, color| label.color(color))
366 .into_any_element()
367 };
368
369 let has_diff_stats = self.added.is_some() || self.removed.is_some();
370 let diff_stat_id = self.id.clone();
371 let added_count = self.added.unwrap_or(0);
372 let removed_count = self.removed.unwrap_or(0);
373
374 let project_paths = self.project_paths.as_ref().and_then(|paths| {
375 let paths_str = paths
376 .as_ref()
377 .iter()
378 .filter_map(|p| p.file_name())
379 .filter_map(|name| name.to_str())
380 .join(", ");
381 if paths_str.is_empty() {
382 None
383 } else {
384 Some(paths_str)
385 }
386 });
387
388 let has_project_name = self.project_name.is_some();
389 let has_project_paths = project_paths.is_some();
390 let has_worktree = !self.worktrees.is_empty() || self.pending_worktree_restore;
391 let has_timestamp = !self.timestamp.is_empty();
392 let timestamp = self.timestamp;
393
394 v_flex()
395 .id(self.id.clone())
396 .cursor_pointer()
397 .group("thread-item")
398 .relative()
399 .overflow_hidden()
400 .w_full()
401 .py_1()
402 .px_1p5()
403 .when(self.selected, |s| s.bg(color.element_active))
404 .border_1()
405 .border_color(gpui::transparent_black())
406 .when(self.focused, |s| s.border_color(color.border_focused))
407 .when(self.rounded, |s| s.rounded_sm())
408 .hover(|s| s.bg(hover_color))
409 .on_hover(self.on_hover)
410 .child(
411 h_flex()
412 .min_w_0()
413 .w_full()
414 .gap_2()
415 .justify_between()
416 .child(
417 h_flex()
418 .id("content")
419 .min_w_0()
420 .flex_1()
421 .gap_1p5()
422 .child(icon)
423 .child(title_label)
424 .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
425 )
426 .child(gradient_overlay)
427 .when(!pending_worktree_restore && self.hovered, |this| {
428 this.when_some(action_slot, |this, slot| {
429 let overlay = GradientFade::new(base_bg, hover_bg, hover_bg)
430 .width(px(64.0))
431 .right(px(6.))
432 .gradient_stop(0.75)
433 .group_name("thread-item");
434
435 this.child(
436 h_flex()
437 .relative()
438 .on_mouse_down(MouseButton::Left, |_, _, cx| {
439 cx.stop_propagation()
440 })
441 .child(overlay)
442 .child(slot),
443 )
444 })
445 }),
446 )
447 .when(
448 has_project_name
449 || has_project_paths
450 || has_worktree
451 || has_diff_stats
452 || has_timestamp,
453 |this| {
454 // Collect all full paths for the shared tooltip.
455 let worktree_tooltip: SharedString = self
456 .worktrees
457 .iter()
458 .map(|wt| wt.full_path.as_ref())
459 .collect::<Vec<_>>()
460 .join("\n")
461 .into();
462
463 let worktree_tooltip_title = if self.worktrees.len() > 1 {
464 "Thread Running in Local Git Worktrees"
465 } else {
466 "Thread Running in a Local Git Worktree"
467 };
468
469 // Deduplicate chips by name — e.g. two paths both named
470 // "olivetti" produce a single chip. Highlight positions
471 // come from the first occurrence.
472 let mut seen_names: Vec<SharedString> = Vec::new();
473 let mut worktree_labels: Vec<AnyElement> = Vec::new();
474
475 for wt in self.worktrees {
476 if seen_names.contains(&wt.name) {
477 continue;
478 }
479
480 let chip_index = seen_names.len();
481 seen_names.push(wt.name.clone());
482
483 let label = if wt.highlight_positions.is_empty() {
484 Label::new(wt.name)
485 .size(LabelSize::Small)
486 .color(Color::Muted)
487 .into_any_element()
488 } else {
489 HighlightedLabel::new(wt.name, wt.highlight_positions)
490 .size(LabelSize::Small)
491 .color(Color::Muted)
492 .into_any_element()
493 };
494 let tooltip_title = worktree_tooltip_title;
495 let tooltip_meta = worktree_tooltip.clone();
496
497 worktree_labels.push(
498 h_flex()
499 .id(format!("{}-worktree-{chip_index}", self.id.clone()))
500 .gap_0p5()
501 .child(
502 Icon::new(IconName::GitWorktree)
503 .size(IconSize::XSmall)
504 .color(Color::Muted),
505 )
506 .child(label)
507 .tooltip(move |_, cx| {
508 Tooltip::with_meta(
509 tooltip_title,
510 None,
511 tooltip_meta.clone(),
512 cx,
513 )
514 })
515 .into_any_element(),
516 );
517 }
518
519 if self.pending_worktree_restore {
520 let on_cancel = self.on_cancel_restore.take();
521 let restore_element = h_flex()
522 .id(format!("{}-worktree-restore", self.id.clone()))
523 .gap_1()
524 .child(
525 Icon::new(IconName::LoadCircle)
526 .size(IconSize::XSmall)
527 .color(Color::Muted)
528 .with_rotate_animation(2),
529 )
530 .child(
531 Label::new("Restoring worktree\u{2026}")
532 .size(LabelSize::Small)
533 .color(Color::Muted),
534 )
535 .when_some(on_cancel, |this, on_cancel| {
536 this.child(
537 IconButton::new(
538 format!("{}-cancel-restore", self.id.clone()),
539 IconName::Close,
540 )
541 .icon_size(IconSize::XSmall)
542 .icon_color(Color::Muted)
543 .tooltip(Tooltip::text("Cancel Restore"))
544 .on_click(
545 move |event, window, cx| {
546 cx.stop_propagation();
547 on_cancel(event, window, cx);
548 },
549 ),
550 )
551 })
552 .tooltip(Tooltip::text("Restoring the Git worktree for this thread"))
553 .into_any_element();
554 worktree_labels.push(restore_element);
555 }
556
557 this.child(
558 h_flex()
559 .min_w_0()
560 .gap_1p5()
561 .child(icon_container()) // Icon Spacing
562 .when_some(self.project_name, |this, name| {
563 this.child(
564 Label::new(name).size(LabelSize::Small).color(Color::Muted),
565 )
566 })
567 .when(
568 has_project_name && (has_project_paths || has_worktree),
569 |this| this.child(dot_separator()),
570 )
571 .when_some(project_paths, |this, paths| {
572 this.child(
573 Label::new(paths)
574 .size(LabelSize::Small)
575 .color(Color::Muted)
576 .into_any_element(),
577 )
578 })
579 .when(has_project_paths && has_worktree, |this| {
580 this.child(dot_separator())
581 })
582 .children(worktree_labels)
583 .when(
584 (has_project_name || has_project_paths || has_worktree)
585 && (has_diff_stats || has_timestamp),
586 |this| this.child(dot_separator()),
587 )
588 .when(has_diff_stats, |this| {
589 this.child(
590 DiffStat::new(diff_stat_id, added_count, removed_count)
591 .tooltip("Unreviewed Changes"),
592 )
593 })
594 .when(has_diff_stats && has_timestamp, |this| {
595 this.child(dot_separator())
596 })
597 .when(has_timestamp, |this| {
598 this.child(
599 Label::new(timestamp.clone())
600 .size(LabelSize::Small)
601 .color(Color::Muted),
602 )
603 }),
604 )
605 },
606 )
607 .when_some(self.on_click, |this, on_click| this.on_click(on_click))
608 }
609}
610
611impl Component for ThreadItem {
612 fn scope() -> ComponentScope {
613 ComponentScope::Agent
614 }
615
616 fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
617 let color = cx.theme().colors();
618 let bg = color
619 .title_bar_background
620 .blend(color.panel_background.opacity(0.25));
621
622 let container = || {
623 v_flex()
624 .w_72()
625 .border_1()
626 .border_color(color.border_variant)
627 .bg(bg)
628 };
629
630 let thread_item_examples = vec![
631 single_example(
632 "Default (minutes)",
633 container()
634 .child(
635 ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
636 .icon(IconName::AiOpenAi)
637 .timestamp("15m"),
638 )
639 .into_any_element(),
640 ),
641 single_example(
642 "Notified (weeks)",
643 container()
644 .child(
645 ThreadItem::new("ti-2", "Refine thread view scrolling behavior")
646 .timestamp("1w")
647 .notified(true),
648 )
649 .into_any_element(),
650 ),
651 single_example(
652 "Waiting for Confirmation",
653 container()
654 .child(
655 ThreadItem::new("ti-2b", "Execute shell command in terminal")
656 .timestamp("2h")
657 .status(AgentThreadStatus::WaitingForConfirmation),
658 )
659 .into_any_element(),
660 ),
661 single_example(
662 "Error",
663 container()
664 .child(
665 ThreadItem::new("ti-2c", "Failed to connect to language server")
666 .timestamp("5h")
667 .status(AgentThreadStatus::Error),
668 )
669 .into_any_element(),
670 ),
671 single_example(
672 "Running Agent",
673 container()
674 .child(
675 ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
676 .icon(IconName::AiClaude)
677 .timestamp("23h")
678 .status(AgentThreadStatus::Running),
679 )
680 .into_any_element(),
681 ),
682 single_example(
683 "In Worktree",
684 container()
685 .child(
686 ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
687 .icon(IconName::AiClaude)
688 .timestamp("2w")
689 .worktrees(vec![ThreadItemWorktreeInfo {
690 name: "link-agent-panel".into(),
691 full_path: "link-agent-panel".into(),
692 highlight_positions: Vec::new(),
693 }]),
694 )
695 .into_any_element(),
696 ),
697 single_example(
698 "With Changes (months)",
699 container()
700 .child(
701 ThreadItem::new("ti-5", "Managing user and project settings interactions")
702 .icon(IconName::AiClaude)
703 .timestamp("1mo")
704 .added(10)
705 .removed(3),
706 )
707 .into_any_element(),
708 ),
709 single_example(
710 "Worktree + Changes + Timestamp",
711 container()
712 .child(
713 ThreadItem::new("ti-5b", "Full metadata example")
714 .icon(IconName::AiClaude)
715 .worktrees(vec![ThreadItemWorktreeInfo {
716 name: "my-project".into(),
717 full_path: "my-project".into(),
718 highlight_positions: Vec::new(),
719 }])
720 .added(42)
721 .removed(17)
722 .timestamp("3w"),
723 )
724 .into_any_element(),
725 ),
726 single_example(
727 "Selected Item",
728 container()
729 .child(
730 ThreadItem::new("ti-6", "Refine textarea interaction behavior")
731 .icon(IconName::AiGemini)
732 .timestamp("45m")
733 .selected(true),
734 )
735 .into_any_element(),
736 ),
737 single_example(
738 "Focused Item (Keyboard Selection)",
739 container()
740 .child(
741 ThreadItem::new("ti-7", "Implement keyboard navigation")
742 .icon(IconName::AiClaude)
743 .timestamp("12h")
744 .focused(true),
745 )
746 .into_any_element(),
747 ),
748 single_example(
749 "Selected + Focused",
750 container()
751 .child(
752 ThreadItem::new("ti-8", "Active and keyboard-focused thread")
753 .icon(IconName::AiGemini)
754 .timestamp("2mo")
755 .selected(true)
756 .focused(true),
757 )
758 .into_any_element(),
759 ),
760 single_example(
761 "Hovered with Action Slot",
762 container()
763 .child(
764 ThreadItem::new("ti-9", "Hover to see action button")
765 .icon(IconName::AiClaude)
766 .timestamp("6h")
767 .hovered(true)
768 .action_slot(
769 IconButton::new("delete", IconName::Trash)
770 .icon_size(IconSize::Small)
771 .icon_color(Color::Muted),
772 ),
773 )
774 .into_any_element(),
775 ),
776 single_example(
777 "Search Highlight",
778 container()
779 .child(
780 ThreadItem::new("ti-10", "Implement keyboard navigation")
781 .icon(IconName::AiClaude)
782 .timestamp("4w")
783 .highlight_positions(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
784 )
785 .into_any_element(),
786 ),
787 single_example(
788 "Worktree Search Highlight",
789 container()
790 .child(
791 ThreadItem::new("ti-11", "Search in worktree name")
792 .icon(IconName::AiClaude)
793 .timestamp("3mo")
794 .worktrees(vec![ThreadItemWorktreeInfo {
795 name: "my-project-name".into(),
796 full_path: "my-project-name".into(),
797 highlight_positions: vec![3, 4, 5, 6, 7, 8, 9, 10, 11],
798 }]),
799 )
800 .into_any_element(),
801 ),
802 ];
803
804 Some(
805 example_group(thread_item_examples)
806 .vertical()
807 .into_any_element(),
808 )
809 }
810}