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