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