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