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 v_flex()
231 .id(self.id.clone())
232 .group("thread-item")
233 .relative()
234 .overflow_hidden()
235 .cursor_pointer()
236 .w_full()
237 .map(|this| {
238 if self.worktree.is_some() {
239 this.p_2()
240 } else {
241 this.px_2().py_1()
242 }
243 })
244 .when(self.selected, |s| s.bg(color.element_active))
245 .border_1()
246 .border_color(gpui::transparent_black())
247 .when(self.focused, |s| s.border_color(color.panel_focused_border))
248 .hover(|s| s.bg(color.element_hover))
249 .on_hover(self.on_hover)
250 .child(
251 h_flex()
252 .min_w_0()
253 .w_full()
254 .gap_2()
255 .justify_between()
256 .child(
257 h_flex()
258 .id("content")
259 .min_w_0()
260 .flex_1()
261 .gap_1p5()
262 .child(icon)
263 .child(title_label)
264 .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
265 )
266 .child(gradient_overlay)
267 .when(running_or_action, |this| {
268 this.child(
269 h_flex()
270 .gap_1()
271 .when(is_running, |this| {
272 this.child(
273 icon_container()
274 .child(SpinnerLabel::new().color(Color::Accent)),
275 )
276 })
277 .when(self.hovered, |this| {
278 this.when_some(self.action_slot, |this, slot| this.child(slot))
279 }),
280 )
281 }),
282 )
283 .when_some(self.worktree, |this, worktree| {
284 let worktree_highlight_positions = self.worktree_highlight_positions;
285 let worktree_label = if worktree_highlight_positions.is_empty() {
286 Label::new(worktree)
287 .size(LabelSize::Small)
288 .color(Color::Muted)
289 .into_any_element()
290 } else {
291 HighlightedLabel::new(worktree, worktree_highlight_positions)
292 .size(LabelSize::Small)
293 .color(Color::Muted)
294 .into_any_element()
295 };
296
297 this.child(
298 h_flex()
299 .min_w_0()
300 .gap_1p5()
301 .child(icon_container()) // Icon Spacing
302 .child(worktree_label)
303 // TODO: Uncomment the elements below when we're ready to expose this data
304 // .child(dot_separator())
305 // .child(
306 // Label::new(self.timestamp)
307 // .size(LabelSize::Small)
308 // .color(Color::Muted),
309 // )
310 // .child(
311 // Label::new("•")
312 // .size(LabelSize::Small)
313 // .color(Color::Muted)
314 // .alpha(0.5),
315 // )
316 // .when(has_no_changes, |this| {
317 // this.child(
318 // Label::new("No Changes")
319 // .size(LabelSize::Small)
320 // .color(Color::Muted),
321 // )
322 // })
323 .when(self.added.is_some() || self.removed.is_some(), |this| {
324 this.child(DiffStat::new(
325 self.id,
326 self.added.unwrap_or(0),
327 self.removed.unwrap_or(0),
328 ))
329 }),
330 )
331 })
332 .when_some(self.on_click, |this, on_click| this.on_click(on_click))
333 }
334}
335
336impl Component for ThreadItem {
337 fn scope() -> ComponentScope {
338 ComponentScope::Agent
339 }
340
341 fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
342 let container = || {
343 v_flex()
344 .w_72()
345 .border_1()
346 .border_color(cx.theme().colors().border_variant)
347 .bg(cx.theme().colors().panel_background)
348 };
349
350 let thread_item_examples = vec![
351 single_example(
352 "Default",
353 container()
354 .child(
355 ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
356 .icon(IconName::AiOpenAi)
357 .timestamp("1:33 AM"),
358 )
359 .into_any_element(),
360 ),
361 single_example(
362 "Notified",
363 container()
364 .child(
365 ThreadItem::new("ti-2", "Refine thread view scrolling behavior")
366 .timestamp("12:12 AM")
367 .notified(true),
368 )
369 .into_any_element(),
370 ),
371 single_example(
372 "Waiting for Confirmation",
373 container()
374 .child(
375 ThreadItem::new("ti-2b", "Execute shell command in terminal")
376 .timestamp("12:15 AM")
377 .status(AgentThreadStatus::WaitingForConfirmation),
378 )
379 .into_any_element(),
380 ),
381 single_example(
382 "Error",
383 container()
384 .child(
385 ThreadItem::new("ti-2c", "Failed to connect to language server")
386 .timestamp("12:20 AM")
387 .status(AgentThreadStatus::Error),
388 )
389 .into_any_element(),
390 ),
391 single_example(
392 "Running Agent",
393 container()
394 .child(
395 ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
396 .icon(IconName::AiClaude)
397 .timestamp("7:30 PM")
398 .status(AgentThreadStatus::Running),
399 )
400 .into_any_element(),
401 ),
402 single_example(
403 "In Worktree",
404 container()
405 .child(
406 ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
407 .icon(IconName::AiClaude)
408 .timestamp("7:37 PM")
409 .worktree("link-agent-panel"),
410 )
411 .into_any_element(),
412 ),
413 single_example(
414 "With Changes",
415 container()
416 .child(
417 ThreadItem::new("ti-5", "Managing user and project settings interactions")
418 .icon(IconName::AiClaude)
419 .timestamp("7:37 PM")
420 .added(10)
421 .removed(3),
422 )
423 .into_any_element(),
424 ),
425 single_example(
426 "Selected Item",
427 container()
428 .child(
429 ThreadItem::new("ti-6", "Refine textarea interaction behavior")
430 .icon(IconName::AiGemini)
431 .timestamp("3:00 PM")
432 .selected(true),
433 )
434 .into_any_element(),
435 ),
436 single_example(
437 "Focused Item (Keyboard Selection)",
438 container()
439 .child(
440 ThreadItem::new("ti-7", "Implement keyboard navigation")
441 .icon(IconName::AiClaude)
442 .timestamp("4:00 PM")
443 .focused(true),
444 )
445 .into_any_element(),
446 ),
447 single_example(
448 "Selected + Focused",
449 container()
450 .child(
451 ThreadItem::new("ti-8", "Active and keyboard-focused thread")
452 .icon(IconName::AiGemini)
453 .timestamp("5:00 PM")
454 .selected(true)
455 .focused(true),
456 )
457 .into_any_element(),
458 ),
459 ];
460
461 Some(
462 example_group(thread_item_examples)
463 .vertical()
464 .into_any_element(),
465 )
466 }
467}