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