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