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