1use crate::{
2 CommonAnimationExt, DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration,
3 IconDecorationKind, 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::WaitingForConfirmation {
210 Some(decoration(
211 IconDecorationKind::Triangle,
212 cx.theme().status().warning,
213 ))
214 } else if self.status == AgentThreadStatus::Error {
215 Some(decoration(IconDecorationKind::X, cx.theme().status().error))
216 } else if self.notified {
217 Some(decoration(IconDecorationKind::Dot, color.text_accent))
218 } else {
219 None
220 };
221
222 let is_running = matches!(
223 self.status,
224 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
225 );
226
227 let icon = if is_running {
228 icon_container().child(
229 Icon::new(IconName::LoadCircle)
230 .size(IconSize::Small)
231 .color(Color::Muted)
232 .with_rotate_animation(2),
233 )
234 } else if let Some(decoration) = decoration {
235 icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration)))
236 } else {
237 icon_container().child(agent_icon)
238 };
239
240 let title = self.title;
241 let highlight_positions = self.highlight_positions;
242 let title_label = if self.generating_title {
243 Label::new(title)
244 .color(Color::Muted)
245 .with_animation(
246 "generating-title",
247 Animation::new(Duration::from_secs(2))
248 .repeat()
249 .with_easing(pulsating_between(0.4, 0.8)),
250 |label, delta| label.alpha(delta),
251 )
252 .into_any_element()
253 } else if highlight_positions.is_empty() {
254 let label = Label::new(title);
255 let label = if let Some(color) = self.title_label_color {
256 label.color(color)
257 } else {
258 label
259 };
260 label.into_any_element()
261 } else {
262 let label = HighlightedLabel::new(title, highlight_positions);
263 let label = if let Some(color) = self.title_label_color {
264 label.color(color)
265 } else {
266 label
267 };
268 label.into_any_element()
269 };
270
271 let b_bg = color
272 .title_bar_background
273 .blend(color.panel_background.opacity(0.8));
274
275 let base_bg = if self.selected {
276 color.element_active
277 } else {
278 b_bg
279 };
280
281 let gradient_overlay =
282 GradientFade::new(base_bg, color.element_hover, color.element_active)
283 .width(px(64.0))
284 .right(px(-10.0))
285 .gradient_stop(0.75)
286 .group_name("thread-item");
287
288 let has_diff_stats = self.added.is_some() || self.removed.is_some();
289 let added_count = self.added.unwrap_or(0);
290 let removed_count = self.removed.unwrap_or(0);
291 let diff_stat_id = self.id.clone();
292 let has_worktree = self.worktree.is_some();
293 let has_timestamp = !self.timestamp.is_empty();
294 let timestamp = self.timestamp;
295
296 v_flex()
297 .id(self.id.clone())
298 .group("thread-item")
299 .relative()
300 .overflow_hidden()
301 .cursor_pointer()
302 .w_full()
303 .p_1()
304 .when(self.selected, |s| s.bg(color.element_active))
305 .border_1()
306 .border_color(gpui::transparent_black())
307 .when(self.focused, |s| {
308 s.when(self.docked_right, |s| s.border_r_2())
309 .border_color(color.border_focused)
310 })
311 .hover(|s| s.bg(color.element_hover))
312 .active(|s| s.bg(color.element_active))
313 .on_hover(self.on_hover)
314 .child(
315 h_flex()
316 .min_w_0()
317 .w_full()
318 .gap_2()
319 .justify_between()
320 .child(
321 h_flex()
322 .id("content")
323 .min_w_0()
324 .flex_1()
325 .gap_1p5()
326 .child(icon)
327 .child(title_label)
328 .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
329 )
330 .child(gradient_overlay)
331 .when(self.hovered, |this| {
332 this.when_some(self.action_slot, |this, slot| {
333 let overlay = GradientFade::new(
334 base_bg,
335 color.element_hover,
336 color.element_active,
337 )
338 .width(px(64.0))
339 .right(px(6.))
340 .gradient_stop(0.75)
341 .group_name("thread-item");
342
343 this.child(
344 h_flex()
345 .relative()
346 .on_mouse_down(MouseButton::Left, |_, _, cx| {
347 cx.stop_propagation()
348 })
349 .child(overlay)
350 .child(slot),
351 )
352 })
353 }),
354 )
355 .when_some(self.worktree, |this, worktree| {
356 let worktree_highlight_positions = self.worktree_highlight_positions;
357 let worktree_label = if worktree_highlight_positions.is_empty() {
358 Label::new(worktree)
359 .size(LabelSize::Small)
360 .color(Color::Muted)
361 .into_any_element()
362 } else {
363 HighlightedLabel::new(worktree, worktree_highlight_positions)
364 .size(LabelSize::Small)
365 .color(Color::Muted)
366 .into_any_element()
367 };
368
369 this.child(
370 h_flex()
371 .min_w_0()
372 .gap_1p5()
373 .child(icon_container()) // Icon Spacing
374 .child(worktree_label)
375 .when(has_diff_stats || has_timestamp, |this| {
376 this.child(dot_separator())
377 })
378 .when(has_diff_stats, |this| {
379 this.child(
380 DiffStat::new(diff_stat_id.clone(), added_count, removed_count)
381 .tooltip("Unreviewed changes"),
382 )
383 })
384 .when(has_diff_stats && has_timestamp, |this| {
385 this.child(dot_separator())
386 })
387 .when(has_timestamp, |this| {
388 this.child(
389 Label::new(timestamp.clone())
390 .size(LabelSize::Small)
391 .color(Color::Muted),
392 )
393 }),
394 )
395 })
396 .when(!has_worktree && (has_diff_stats || has_timestamp), |this| {
397 this.child(
398 h_flex()
399 .min_w_0()
400 .gap_1p5()
401 .child(icon_container()) // Icon Spacing
402 .when(has_diff_stats, |this| {
403 this.child(
404 DiffStat::new(diff_stat_id, added_count, removed_count)
405 .tooltip("Unreviewed Changes"),
406 )
407 })
408 .when(has_diff_stats && has_timestamp, |this| {
409 this.child(dot_separator())
410 })
411 .when(has_timestamp, |this| {
412 this.child(
413 Label::new(timestamp.clone())
414 .size(LabelSize::Small)
415 .color(Color::Muted),
416 )
417 }),
418 )
419 })
420 .when_some(self.on_click, |this, on_click| this.on_click(on_click))
421 }
422}
423
424impl Component for ThreadItem {
425 fn scope() -> ComponentScope {
426 ComponentScope::Agent
427 }
428
429 fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
430 let container = || {
431 v_flex()
432 .w_72()
433 .border_1()
434 .border_color(cx.theme().colors().border_variant)
435 .bg(cx.theme().colors().panel_background)
436 };
437
438 let thread_item_examples = vec![
439 single_example(
440 "Default (minutes)",
441 container()
442 .child(
443 ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
444 .icon(IconName::AiOpenAi)
445 .timestamp("15m"),
446 )
447 .into_any_element(),
448 ),
449 single_example(
450 "Timestamp Only (hours)",
451 container()
452 .child(
453 ThreadItem::new("ti-1b", "Thread with just a timestamp")
454 .icon(IconName::AiClaude)
455 .timestamp("3h"),
456 )
457 .into_any_element(),
458 ),
459 single_example(
460 "Notified (weeks)",
461 container()
462 .child(
463 ThreadItem::new("ti-2", "Refine thread view scrolling behavior")
464 .timestamp("1w")
465 .notified(true),
466 )
467 .into_any_element(),
468 ),
469 single_example(
470 "Waiting for Confirmation",
471 container()
472 .child(
473 ThreadItem::new("ti-2b", "Execute shell command in terminal")
474 .timestamp("2h")
475 .status(AgentThreadStatus::WaitingForConfirmation),
476 )
477 .into_any_element(),
478 ),
479 single_example(
480 "Error",
481 container()
482 .child(
483 ThreadItem::new("ti-2c", "Failed to connect to language server")
484 .timestamp("5h")
485 .status(AgentThreadStatus::Error),
486 )
487 .into_any_element(),
488 ),
489 single_example(
490 "Running Agent",
491 container()
492 .child(
493 ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
494 .icon(IconName::AiClaude)
495 .timestamp("23h")
496 .status(AgentThreadStatus::Running),
497 )
498 .into_any_element(),
499 ),
500 single_example(
501 "In Worktree",
502 container()
503 .child(
504 ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
505 .icon(IconName::AiClaude)
506 .timestamp("2w")
507 .worktree("link-agent-panel"),
508 )
509 .into_any_element(),
510 ),
511 single_example(
512 "With Changes (months)",
513 container()
514 .child(
515 ThreadItem::new("ti-5", "Managing user and project settings interactions")
516 .icon(IconName::AiClaude)
517 .timestamp("1mo")
518 .added(10)
519 .removed(3),
520 )
521 .into_any_element(),
522 ),
523 single_example(
524 "Worktree + Changes + Timestamp",
525 container()
526 .child(
527 ThreadItem::new("ti-5b", "Full metadata example")
528 .icon(IconName::AiClaude)
529 .worktree("my-project")
530 .added(42)
531 .removed(17)
532 .timestamp("3w"),
533 )
534 .into_any_element(),
535 ),
536 single_example(
537 "Selected Item",
538 container()
539 .child(
540 ThreadItem::new("ti-6", "Refine textarea interaction behavior")
541 .icon(IconName::AiGemini)
542 .timestamp("45m")
543 .selected(true),
544 )
545 .into_any_element(),
546 ),
547 single_example(
548 "Focused Item (Keyboard Selection)",
549 container()
550 .child(
551 ThreadItem::new("ti-7", "Implement keyboard navigation")
552 .icon(IconName::AiClaude)
553 .timestamp("12h")
554 .focused(true),
555 )
556 .into_any_element(),
557 ),
558 single_example(
559 "Focused + Docked Right",
560 container()
561 .child(
562 ThreadItem::new("ti-7b", "Focused with right dock border")
563 .icon(IconName::AiClaude)
564 .timestamp("1w")
565 .focused(true)
566 .docked_right(true),
567 )
568 .into_any_element(),
569 ),
570 single_example(
571 "Selected + Focused",
572 container()
573 .child(
574 ThreadItem::new("ti-8", "Active and keyboard-focused thread")
575 .icon(IconName::AiGemini)
576 .timestamp("2mo")
577 .selected(true)
578 .focused(true),
579 )
580 .into_any_element(),
581 ),
582 single_example(
583 "Hovered with Action Slot",
584 container()
585 .child(
586 ThreadItem::new("ti-9", "Hover to see action button")
587 .icon(IconName::AiClaude)
588 .timestamp("6h")
589 .hovered(true)
590 .action_slot(
591 IconButton::new("delete", IconName::Trash)
592 .icon_size(IconSize::Small)
593 .icon_color(Color::Muted),
594 ),
595 )
596 .into_any_element(),
597 ),
598 single_example(
599 "Search Highlight",
600 container()
601 .child(
602 ThreadItem::new("ti-10", "Implement keyboard navigation")
603 .icon(IconName::AiClaude)
604 .timestamp("4w")
605 .highlight_positions(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
606 )
607 .into_any_element(),
608 ),
609 single_example(
610 "Worktree Search Highlight",
611 container()
612 .child(
613 ThreadItem::new("ti-11", "Search in worktree name")
614 .icon(IconName::AiClaude)
615 .timestamp("3mo")
616 .worktree("my-project-name")
617 .worktree_highlight_positions(vec![3, 4, 5, 6, 7, 8, 9, 10, 11]),
618 )
619 .into_any_element(),
620 ),
621 ];
622
623 Some(
624 example_group(thread_item_examples)
625 .vertical()
626 .into_any_element(),
627 )
628 }
629}