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