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