1use crate::{
2 DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration, IconDecorationKind,
3 SpinnerLabel, 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 added: Option<usize>,
30 removed: Option<usize>,
31 worktree: Option<SharedString>,
32 highlight_positions: Vec<usize>,
33 worktree_highlight_positions: Vec<usize>,
34 on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
35 on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
36 action_slot: Option<AnyElement>,
37 tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
38}
39
40impl ThreadItem {
41 pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
42 Self {
43 id: id.into(),
44 icon: IconName::ZedAgent,
45 custom_icon_from_external_svg: None,
46 title: title.into(),
47 timestamp: "".into(),
48 notified: false,
49 status: AgentThreadStatus::default(),
50 selected: false,
51 focused: false,
52 hovered: false,
53 added: None,
54 removed: None,
55 worktree: None,
56 highlight_positions: Vec::new(),
57 worktree_highlight_positions: Vec::new(),
58 on_click: None,
59 on_hover: Box::new(|_, _, _| {}),
60 action_slot: None,
61 tooltip: None,
62 }
63 }
64
65 pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
66 self.timestamp = timestamp.into();
67 self
68 }
69
70 pub fn icon(mut self, icon: IconName) -> Self {
71 self.icon = icon;
72 self
73 }
74
75 pub fn custom_icon_from_external_svg(mut self, svg: impl Into<SharedString>) -> Self {
76 self.custom_icon_from_external_svg = Some(svg.into());
77 self
78 }
79
80 pub fn notified(mut self, notified: bool) -> Self {
81 self.notified = notified;
82 self
83 }
84
85 pub fn status(mut self, status: AgentThreadStatus) -> Self {
86 self.status = status;
87 self
88 }
89
90 pub fn selected(mut self, selected: bool) -> Self {
91 self.selected = selected;
92 self
93 }
94
95 pub fn focused(mut self, focused: bool) -> Self {
96 self.focused = focused;
97 self
98 }
99
100 pub fn added(mut self, added: usize) -> Self {
101 self.added = Some(added);
102 self
103 }
104
105 pub fn removed(mut self, removed: usize) -> Self {
106 self.removed = Some(removed);
107 self
108 }
109
110 pub fn worktree(mut self, worktree: impl Into<SharedString>) -> Self {
111 self.worktree = Some(worktree.into());
112 self
113 }
114
115 pub fn highlight_positions(mut self, positions: Vec<usize>) -> Self {
116 self.highlight_positions = positions;
117 self
118 }
119
120 pub fn worktree_highlight_positions(mut self, positions: Vec<usize>) -> Self {
121 self.worktree_highlight_positions = positions;
122 self
123 }
124
125 pub fn hovered(mut self, hovered: bool) -> Self {
126 self.hovered = hovered;
127 self
128 }
129
130 pub fn on_click(
131 mut self,
132 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
133 ) -> Self {
134 self.on_click = Some(Box::new(handler));
135 self
136 }
137
138 pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
139 self.on_hover = Box::new(on_hover);
140 self
141 }
142
143 pub fn action_slot(mut self, element: impl IntoElement) -> Self {
144 self.action_slot = Some(element.into_any_element());
145 self
146 }
147
148 pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
149 self.tooltip = Some(Box::new(tooltip));
150 self
151 }
152}
153
154impl RenderOnce for ThreadItem {
155 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
156 let color = cx.theme().colors();
157 // let dot_separator = || {
158 // Label::new("•")
159 // .size(LabelSize::Small)
160 // .color(Color::Muted)
161 // .alpha(0.5)
162 // };
163
164 let icon_container = || h_flex().size_4().flex_none().justify_center();
165 let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg {
166 Icon::from_external_svg(custom_svg)
167 .color(Color::Muted)
168 .size(IconSize::Small)
169 } else {
170 Icon::new(self.icon)
171 .color(Color::Muted)
172 .size(IconSize::Small)
173 };
174
175 let decoration = |icon: IconDecorationKind, color: Hsla| {
176 IconDecoration::new(icon, cx.theme().colors().surface_background, cx)
177 .color(color)
178 .position(gpui::Point {
179 x: px(-2.),
180 y: px(-2.),
181 })
182 };
183
184 let decoration = if self.status == AgentThreadStatus::WaitingForConfirmation {
185 Some(decoration(
186 IconDecorationKind::Triangle,
187 cx.theme().status().warning,
188 ))
189 } else if self.status == AgentThreadStatus::Error {
190 Some(decoration(IconDecorationKind::X, cx.theme().status().error))
191 } else if self.notified {
192 Some(decoration(IconDecorationKind::Dot, color.text_accent))
193 } else {
194 None
195 };
196
197 let icon = if let Some(decoration) = decoration {
198 icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration)))
199 } else {
200 icon_container().child(agent_icon)
201 };
202
203 let is_running = matches!(
204 self.status,
205 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
206 );
207 let running_or_action = is_running || (self.hovered && self.action_slot.is_some());
208
209 let title = self.title;
210 let highlight_positions = self.highlight_positions;
211 let title_label = if highlight_positions.is_empty() {
212 Label::new(title).into_any_element()
213 } else {
214 HighlightedLabel::new(title, highlight_positions).into_any_element()
215 };
216
217 let base_bg = if self.selected {
218 color.element_active
219 } else {
220 color.panel_background
221 };
222
223 let gradient_overlay =
224 GradientFade::new(base_bg, color.element_hover, color.element_active)
225 .width(px(32.0))
226 .right(px(-10.0))
227 .gradient_stop(0.8)
228 .group_name("thread-item");
229
230 let has_diff_stats = self.added.is_some() || self.removed.is_some();
231 let added_count = self.added.unwrap_or(0);
232 let removed_count = self.removed.unwrap_or(0);
233 let diff_stat_id = self.id.clone();
234 let has_worktree = self.worktree.is_some();
235
236 v_flex()
237 .id(self.id.clone())
238 .group("thread-item")
239 .relative()
240 .overflow_hidden()
241 .cursor_pointer()
242 .w_full()
243 .map(|this| {
244 if has_worktree || has_diff_stats {
245 this.p_2()
246 } else {
247 this.px_2().py_1()
248 }
249 })
250 .when(self.selected, |s| s.bg(color.element_active))
251 .border_1()
252 .border_color(gpui::transparent_black())
253 .when(self.focused, |s| s.border_color(color.panel_focused_border))
254 .hover(|s| s.bg(color.element_hover))
255 .on_hover(self.on_hover)
256 .child(
257 h_flex()
258 .min_w_0()
259 .w_full()
260 .gap_2()
261 .justify_between()
262 .child(
263 h_flex()
264 .id("content")
265 .min_w_0()
266 .flex_1()
267 .gap_1p5()
268 .child(icon)
269 .child(title_label)
270 .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
271 )
272 .child(gradient_overlay)
273 .when(running_or_action, |this| {
274 this.child(
275 h_flex()
276 .gap_1()
277 .when(is_running, |this| {
278 this.child(
279 icon_container()
280 .child(SpinnerLabel::new().color(Color::Accent)),
281 )
282 })
283 .when(self.hovered, |this| {
284 this.when_some(self.action_slot, |this, slot| this.child(slot))
285 }),
286 )
287 }),
288 )
289 .when_some(self.worktree, |this, worktree| {
290 let worktree_highlight_positions = self.worktree_highlight_positions;
291 let worktree_label = if worktree_highlight_positions.is_empty() {
292 Label::new(worktree)
293 .size(LabelSize::Small)
294 .color(Color::Muted)
295 .into_any_element()
296 } else {
297 HighlightedLabel::new(worktree, worktree_highlight_positions)
298 .size(LabelSize::Small)
299 .color(Color::Muted)
300 .into_any_element()
301 };
302
303 this.child(
304 h_flex()
305 .min_w_0()
306 .gap_1p5()
307 .child(icon_container()) // Icon Spacing
308 .child(worktree_label)
309 .when(has_diff_stats, |this| {
310 this.child(DiffStat::new(
311 diff_stat_id.clone(),
312 added_count,
313 removed_count,
314 ))
315 }),
316 )
317 })
318 .when(!has_worktree && has_diff_stats, |this| {
319 this.child(
320 h_flex()
321 .min_w_0()
322 .gap_1p5()
323 .child(icon_container()) // Icon Spacing
324 .child(DiffStat::new(diff_stat_id, added_count, removed_count)),
325 )
326 })
327 .when_some(self.on_click, |this, on_click| this.on_click(on_click))
328 }
329}
330
331impl Component for ThreadItem {
332 fn scope() -> ComponentScope {
333 ComponentScope::Agent
334 }
335
336 fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
337 let container = || {
338 v_flex()
339 .w_72()
340 .border_1()
341 .border_color(cx.theme().colors().border_variant)
342 .bg(cx.theme().colors().panel_background)
343 };
344
345 let thread_item_examples = vec![
346 single_example(
347 "Default",
348 container()
349 .child(
350 ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
351 .icon(IconName::AiOpenAi)
352 .timestamp("1:33 AM"),
353 )
354 .into_any_element(),
355 ),
356 single_example(
357 "Notified",
358 container()
359 .child(
360 ThreadItem::new("ti-2", "Refine thread view scrolling behavior")
361 .timestamp("12:12 AM")
362 .notified(true),
363 )
364 .into_any_element(),
365 ),
366 single_example(
367 "Waiting for Confirmation",
368 container()
369 .child(
370 ThreadItem::new("ti-2b", "Execute shell command in terminal")
371 .timestamp("12:15 AM")
372 .status(AgentThreadStatus::WaitingForConfirmation),
373 )
374 .into_any_element(),
375 ),
376 single_example(
377 "Error",
378 container()
379 .child(
380 ThreadItem::new("ti-2c", "Failed to connect to language server")
381 .timestamp("12:20 AM")
382 .status(AgentThreadStatus::Error),
383 )
384 .into_any_element(),
385 ),
386 single_example(
387 "Running Agent",
388 container()
389 .child(
390 ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
391 .icon(IconName::AiClaude)
392 .timestamp("7:30 PM")
393 .status(AgentThreadStatus::Running),
394 )
395 .into_any_element(),
396 ),
397 single_example(
398 "In Worktree",
399 container()
400 .child(
401 ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
402 .icon(IconName::AiClaude)
403 .timestamp("7:37 PM")
404 .worktree("link-agent-panel"),
405 )
406 .into_any_element(),
407 ),
408 single_example(
409 "With Changes",
410 container()
411 .child(
412 ThreadItem::new("ti-5", "Managing user and project settings interactions")
413 .icon(IconName::AiClaude)
414 .timestamp("7:37 PM")
415 .added(10)
416 .removed(3),
417 )
418 .into_any_element(),
419 ),
420 single_example(
421 "Selected Item",
422 container()
423 .child(
424 ThreadItem::new("ti-6", "Refine textarea interaction behavior")
425 .icon(IconName::AiGemini)
426 .timestamp("3:00 PM")
427 .selected(true),
428 )
429 .into_any_element(),
430 ),
431 single_example(
432 "Focused Item (Keyboard Selection)",
433 container()
434 .child(
435 ThreadItem::new("ti-7", "Implement keyboard navigation")
436 .icon(IconName::AiClaude)
437 .timestamp("4:00 PM")
438 .focused(true),
439 )
440 .into_any_element(),
441 ),
442 single_example(
443 "Selected + Focused",
444 container()
445 .child(
446 ThreadItem::new("ti-8", "Active and keyboard-focused thread")
447 .icon(IconName::AiGemini)
448 .timestamp("5:00 PM")
449 .selected(true)
450 .focused(true),
451 )
452 .into_any_element(),
453 ),
454 ];
455
456 Some(
457 example_group(thread_item_examples)
458 .vertical()
459 .into_any_element(),
460 )
461 }
462}