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