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