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