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