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