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